#include "dc_terrain.h"
#include <thread>
#include "dc_octree.h"
#include "dc_edit.h"
#include "dc_contour.h"
#include "dc_seam.h"
#include "dc_density_grid.h"
#include "dc_generator_mesh_sdf.h"
#include "dc_gpu_thread.h"
// Removed: dc_lod_dag.h (RT path — re-add later)
#include <algorithm>
#include <chrono>
#include <godot_cpp/classes/mesh.hpp>
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/image_texture.hpp>
#include <godot_cpp/classes/immediate_mesh.hpp>
#include <godot_cpp/classes/standard_material3d.hpp>
#include <godot_cpp/classes/world3d.hpp>
#include <godot_cpp/classes/viewport.hpp>
#include <godot_cpp/classes/camera3d.hpp>
#include <godot_cpp/variant/packed_vector3_array.hpp>
#include <godot_cpp/variant/packed_int32_array.hpp>
#include <godot_cpp/variant/packed_float32_array.hpp>
#include <godot_cpp/variant/array.hpp>
#include <godot_cpp/variant/dictionary.hpp>
#include <godot_cpp/variant/transform3d.hpp>
#include <godot_cpp/core/class_db.hpp>
#include <godot_cpp/classes/engine.hpp>
#include <godot_cpp/classes/editor_interface.hpp>
#include <godot_cpp/classes/os.hpp>
#include <godot_cpp/classes/sub_viewport.hpp>
#include <chrono>
#include <algorithm>
#include <cstdio>
#include <cmath>
#include <godot_cpp/variant/utility_functions.hpp>
namespace godot {
// ---------------------------------------------------------------------------
// Per-chunk edit culling: filter the global edit list to only edits that
// overlap this chunk's AABB. Converts O(all_edits) to O(nearby_edits)
// per density sample — typically 0-5 instead of thousands.
// ---------------------------------------------------------------------------
static std::shared_ptr<const std::vector<TerrainEdit>> cull_edits_for_chunk(
const std::shared_ptr<const std::vector<TerrainEdit>>& all_edits,
const float chunk_min[3], const float chunk_max[3])
{
if (!all_edits || all_edits->empty()) return all_edits;
float cx = (chunk_min[0] + chunk_max[0]) * 0.5f;
float cy = (chunk_min[1] + chunk_max[1]) * 0.5f;
float cz = (chunk_min[2] + chunk_max[2]) * 0.5f;
float hx = (chunk_max[0] - chunk_min[0]) * 0.5f;
float hy = (chunk_max[1] - chunk_min[1]) * 0.5f;
float hz = (chunk_max[2] - chunk_min[2]) * 0.5f;
std::vector<TerrainEdit> culled;
for (const auto& e : *all_edits) {
bool overlaps = false;
if (e.shape == TerrainEdit::BOX) {
// AABB-AABB overlap (exact)
overlaps = std::fabs(e.x - cx) <= e.half_ext[0] + hx &&
std::fabs(e.y - cy) <= e.half_ext[1] + hy &&
std::fabs(e.z - cz) <= e.half_ext[2] + hz;
} else {
// Sphere-AABB overlap (sphere/mesh edits)
float dx = std::max(0.0f, std::max(chunk_min[0] - e.x, e.x - chunk_max[0]));
float dy = std::max(0.0f, std::max(chunk_min[1] - e.y, e.y - chunk_max[1]));
float dz = std::max(0.0f, std::max(chunk_min[2] - e.z, e.z - chunk_max[2]));
overlaps = dx*dx + dy*dy + dz*dz <= e.radius * e.radius;
}
if (overlaps) culled.push_back(e);
}
if (culled.empty()) return nullptr;
if (culled.size() == all_edits->size()) return all_edits;
return std::make_shared<const std::vector<TerrainEdit>>(std::move(culled));
}
// Static shared editor worker (1 RenderingDevice across all editor instances)
std::unique_ptr<DCWorkerCPU> DCTerrain::s_editor_worker_ = nullptr;
int DCTerrain::s_editor_refcount_ = 0;
// ---------------------------------------------------------------------------
// Worker access helpers — abstract over per-instance vs shared editor worker
// ---------------------------------------------------------------------------
bool DCTerrain::_has_workers() const {
if (using_editor_worker_) return s_editor_worker_ != nullptr;
return !workers_.empty();
}
bool DCTerrain::_submit_to_worker(const CellRequest& req) {
if (using_editor_worker_) {
if (!s_editor_worker_) {
UtilityFunctions::print("DCTerrain: _submit_to_worker FAILED — no editor worker!");
return false;
}
return s_editor_worker_->submit(req);
}
if (workers_.empty()) return false;
bool ok = workers_[next_worker_]->submit(req);
next_worker_ = (next_worker_ + 1) % (int)workers_.size();
return ok;
}
// Route a user-edit remesh through the worker's high-priority lane so it is
// processed before any queued LOD/pre-mesh work (1b). Mirrors the round-robin
// spread of _submit_to_worker.
bool DCTerrain::_submit_to_worker_priority(const CellRequest& req) {
if (using_editor_worker_) {
if (!s_editor_worker_) return false;
return s_editor_worker_->submit_priority(req);
}
if (workers_.empty()) return false;
bool ok = workers_[next_worker_]->submit_priority(req);
next_worker_ = (next_worker_ + 1) % (int)workers_.size();
return ok;
}
// Total edits still queued in the priority lanes across all workers (1b).
// Used to suppress speculative pre-mesh while edits are in flight.
size_t DCTerrain::_worker_priority_depth() const {
if (using_editor_worker_) {
return s_editor_worker_ ? s_editor_worker_->priority_queue_size() : 0;
}
size_t d = 0;
for (auto& w : workers_) d += w->priority_queue_size();
return d;
}
void DCTerrain::_poll_workers(std::function<void(CellResult&)> handler, double budget_ms, int min_count) {
int count = 0;
auto t_start = std::chrono::high_resolution_clock::now();
auto poll_one = [&](DCWorkerCPU* w) {
CellResult result;
while (w->poll_result(result)) {
handler(result);
count++;
if (count >= min_count && budget_ms > 0) {
auto elapsed = std::chrono::duration<double, std::milli>(
std::chrono::high_resolution_clock::now() - t_start).count();
if (elapsed >= budget_ms) return false;
}
}
return true;
};
if (using_editor_worker_) {
if (s_editor_worker_) poll_one(s_editor_worker_.get());
} else {
for (auto& w : workers_) {
if (!poll_one(w.get())) break;
}
}
}
void DCTerrain::_set_worker_generation(uint32_t gen) {
if (using_editor_worker_) {
if (s_editor_worker_) s_editor_worker_->set_generation(gen);
} else {
for (auto& w : workers_) w->set_generation(gen);
}
}
void DCTerrain::_drain_all_workers() {
// Wait for busy workers
auto is_any_busy = [this]() {
if (using_editor_worker_) {
return s_editor_worker_ && s_editor_worker_->is_busy();
}
for (auto& w : workers_) {
if (w->is_busy()) return true;
}
return false;
};
while (is_any_busy()) {
std::this_thread::sleep_for(std::chrono::microseconds(100));
}
// Drain results
CellResult discard;
if (using_editor_worker_) {
if (s_editor_worker_) {
while (s_editor_worker_->poll_result(discard)) {}
}
} else {
for (auto& w : workers_) {
while (w->poll_result(discard)) {}
}
}
}
// ---------------------------------------------------------------------------
// Constructor / Destructor
// ---------------------------------------------------------------------------
DCTerrain::DCTerrain()
: generator_ptr_(&noise_generator_) {
}
DCTerrain::~DCTerrain() {
// Stop per-instance workers
for (auto& worker : workers_) {
worker->stop();
}
workers_.clear();
// Release shared editor worker ref if still held
if (using_editor_worker_) {
s_editor_refcount_--;
if (s_editor_refcount_ <= 0) {
if (s_editor_worker_) {
s_editor_worker_->stop();
s_editor_worker_.reset();
}
s_editor_refcount_ = 0;
}
using_editor_worker_ = false;
}
// Free all node RIDs via octree traversal
auto leaves = octree_clipmap_.get_leaves();
for (auto* node : leaves) {
_free_node_rids(node);
}
// Flush dealloc queue
for (auto& entry : dealloc_queue_) {
RenderingServer* rs = RenderingServer::get_singleton();
if (rs && entry.rs_instance.is_valid()) {
rs->free_rid(entry.rs_instance);
}
if (rs && entry.seam_mesh_instance.is_valid()) {
rs->free_rid(entry.seam_mesh_instance);
}
PhysicsServer3D* ps = PhysicsServer3D::get_singleton();
if (ps) {
if (entry.collision_body.is_valid()) ps->free_rid(entry.collision_body);
if (entry.collision_shape.is_valid()) ps->free_rid(entry.collision_shape);
}
entry.mesh.unref();
}
dealloc_queue_.clear();
collision_trimesh_.clear();
seam_pending_.clear();
node_meshes_.clear();
seam_meshes_.clear();
fade_overrides_.clear();
octree_clipmap_.clear();
}
// ---------------------------------------------------------------------------
// Godot lifecycle
// ---------------------------------------------------------------------------
void DCTerrain::_ready() {
set_process(true);
// Enable wireframe data generation so debug_wireframe toggle works (game mode only)
if (!Engine::get_singleton()->is_editor_hint()) {
RenderingServer::get_singleton()->set_debug_generate_wireframes(true);
}
// Vulkan context and RT injection are deferred to first _process() frame
// because RenderingDevice may not be fully initialized during _ready().
// Detect fork RT injection methods (backward-compatible: no-op on stock Godot)
{
RenderingDevice* rd = RenderingServer::get_singleton()->get_rendering_device();
}
// Configure density
_propagate_config();
// Configure octree clipmap
octree_clipmap_.base_voxel_size = voxel_size_;
octree_clipmap_.voxels_per_cell = 28; // nominal = 32 - 2*overlap(2) = 28
octree_clipmap_.lod_factor = lod_factor_;
octree_clipmap_.hysteresis = hysteresis_;
// DISABLED (was true): content-aware LOD inverts the split/merge hysteresis for
// empty cells. The empty-cell merge collapse (dc_octree_clipmap.cpp:430-432,
// merge_factor = lod_factor*0.5 = 1.0) drops the merge threshold BELOW the split
// threshold (2.0*cs), so every empty cell in the (1.0,2.0)*cs band oscillates
// split<->merge forever on a stationary camera -> never settles, pre-mesh
// pipeline saturates, near-camera chunks never refine. Matches the header default
// (dc_octree_clipmap.h:309) and the disabled-by-default use_screen_space_error,
// whose comment documents this exact split-merge thrash.
octree_clipmap_.content_aware_lod = false;
octree_clipmap_.max_lod_levels = octree_depth_;
// seamless_lod: force all chunks to split to LOD 0 by using a huge lod_factor.
// This makes the distance threshold always trigger splits regardless of distance.
octree_clipmap_.lod_factor = seamless_lod_ ? 1000.0f : 2.0f;
octree_clipmap_.max_splits_per_frame = 16;
octree_clipmap_.max_merges_per_frame = 8;
octree_clipmap_.content_aware_lod = false; // redundant duplicate — see rationale above
octree_clipmap_.lod_mode = lod_mode_;
octree_clipmap_.on_node_free = _on_node_free;
octree_clipmap_.on_mesh_free = _on_mesh_free;
octree_clipmap_.on_collision_free = _on_collision_free;
octree_clipmap_.on_node_free_userdata = this;
// Only initialize if ENTER_TREE hasn't already done it
// (in editor mode, _process can run between ENTER_TREE and _ready,
// so double-init causes use-after-free when worker holds stale pointer)
if (octree_clipmap_.get_stats().leaf_count == 0) {
octree_clipmap_.initialize();
}
// Create unified terrain shader (used by ALL chunks — settled and fading)
terrain_shader_.instantiate();
terrain_shader_->set_code(
"shader_type spatial;\n"
"render_mode cull_back;\n"
"\n"
"uniform float fade_t : hint_range(0.0, 1.0) = 1.0;\n"
"uniform vec3 lod_color : source_color = vec3(0.4, 0.5, 0.3);\n"
"uniform bool use_lod_color = false;\n"
"uniform float normal_perturbation : hint_range(0.0, 1.0) = 0.0;\n"
"uniform float triplanar_scale = 0.15;\n"
"uniform vec3 grass_color : source_color = vec3(0.25, 0.45, 0.15);\n"
"uniform vec3 rock_color : source_color = vec3(0.35, 0.30, 0.25);\n"
"uniform vec3 dirt_color : source_color = vec3(0.30, 0.22, 0.12);\n"
"uniform sampler2DArray tex_atlas : source_color, filter_nearest_mipmap, repeat_enable;\n" // nearest = crisp Quake pixels (no magnification blur); mipmaps keep distance clean
"uniform bool use_textures = false;\n"
// glow_atlas is RGBA: rgb = emissive glow, a = gloss (flipped to roughness
// below). Packed by QuakeWadLoader._pack_glow_rough because both come from
// the same source resolution, so one textureGrad serves both — and under
// path tracing that fetch is paid per hit.
"uniform sampler2DArray glow_atlas : source_color, filter_nearest_mipmap, repeat_enable;\n" // rgb=glow, a=gloss
"uniform sampler2DArray norm_atlas : filter_nearest_mipmap, repeat_enable;\n" // PBR normal (NEAREST: pixelated, like the diffuse)
"uniform float normal_strength : hint_range(0.0, 2.0) = 1.0;\n" // CANONICAL: DarkPlaces applies the normal unscaled
"uniform bool use_pbr = false;\n"
"uniform float tex_tile = 1.792;\n" // world metres per texture tile (~64 quake u * 0.028)
"uniform sampler2D projection_table : filter_nearest;\n" // per-face UV projections (3 texels/row)
"uniform bool has_projection_table = false;\n"
"uniform int projection_rows = 0;\n"
"\n"
"// Quake axial base axes (quake Z-up): floor/ceil/west/east/south/north {normal,u,v}.\n"
"const vec3 QN[6] = vec3[6](vec3(0,0,1),vec3(0,0,-1),vec3(1,0,0),vec3(-1,0,0),vec3(0,1,0),vec3(0,-1,0));\n"
"const vec3 QU[6] = vec3[6](vec3(1,0,0),vec3(1,0,0),vec3(0,1,0),vec3(0,1,0),vec3(1,0,0),vec3(1,0,0));\n"
"const vec3 QV[6] = vec3[6](vec3(0,-1,0),vec3(0,-1,0),vec3(0,0,-1),vec3(0,0,-1),vec3(0,0,-1),vec3(0,0,-1));\n"
"\n"
"varying vec3 world_pos;\n"
"varying vec3 world_normal;\n"
"varying flat float v_is_brush;\n" // FLAT: material must NOT interpolate across
"varying flat float v_mat;\n" // triangles (else rainbow fringe at seams)
"varying flat float v_proj;\n" // per-face projection candidate 0 (0 = none → axial fallback)
"varying flat float v_proj2;\n" // candidate 1 (crease other face; picked per-fragment)
"\n"
"// Simple hash noise\n"
"float hash(vec3 p) {\n"
" p = fract(p * vec3(443.897, 441.423, 437.195));\n"
" p += dot(p, p.yzx + 19.19);\n"
" return fract((p.x + p.y) * p.z);\n"
"}\n"
"\n"
"float noise3d(vec3 p) {\n"
" vec3 i = floor(p);\n"
" vec3 f = fract(p);\n"
" f = f * f * (3.0 - 2.0 * f);\n"
" return mix(\n"
" mix(mix(hash(i), hash(i + vec3(1,0,0)), f.x),\n"
" mix(hash(i + vec3(0,1,0)), hash(i + vec3(1,1,0)), f.x), f.y),\n"
" mix(mix(hash(i + vec3(0,0,1)), hash(i + vec3(1,0,1)), f.x),\n"
" mix(hash(i + vec3(0,1,1)), hash(i + vec3(1,1,1)), f.x), f.y),\n"
" f.z);\n"
"}\n"
"\n"
"void vertex() {\n"
" world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;\n"
" world_normal = normalize((MODEL_MATRIX * vec4(NORMAL, 0.0)).xyz);\n"
" v_is_brush = (COLOR.a > 0.5 && COLOR.g < 0.5 && COLOR.b < 0.5) ? 1.0 : 0.0;\n"
" v_mat = COLOR.r;\n"
" v_proj = CUSTOM0.x;\n" // per-face projection candidates (ARRAY_CUSTOM0, RG_FLOAT)
" v_proj2 = CUSTOM0.y;\n"
"}\n"
"\n"
"void fragment() {\n"
" // Triplanar blend weights from world normal\n"
" vec3 blend = abs(world_normal);\n"
" blend = pow(blend, vec3(4.0)); // sharpen blend\n"
" blend /= (blend.x + blend.y + blend.z + 0.001);\n"
"\n"
" // Triplanar noise for texture variation\n"
" vec3 sp = world_pos * triplanar_scale;\n"
" float n_xy = noise3d(sp.xyz);\n"
" float n_xz = noise3d(sp.xzy);\n"
" float n_yz = noise3d(sp.yxz);\n"
" float tri_noise = n_xy * blend.z + n_xz * blend.y + n_yz * blend.x;\n"
"\n"
" // Fine detail noise\n"
" float detail = noise3d(world_pos * 0.5) * 0.3 + noise3d(world_pos * 1.7) * 0.15;\n"
"\n"
" // Y-up grass blend: tops get grass, sides/bottom get rock\n"
" float up_factor = world_normal.y; // 1.0 = flat top, 0.0 = vertical, -1.0 = underside\n"
" float grass_threshold = 0.65 + (tri_noise - 0.5) * 0.4; // noise at edges\n"
" float grass_t = smoothstep(grass_threshold - 0.1, grass_threshold + 0.1, up_factor);\n"
"\n"
" // Height-based: no grass below Y=0\n"
" float height_fade = smoothstep(-5.0, 5.0, world_pos.y);\n"
" grass_t *= height_fade;\n"
"\n"
" // Dirt on steep slopes (between grass and rock)\n"
" float dirt_t = smoothstep(0.3, 0.5, up_factor) * (1.0 - grass_t);\n"
"\n"
" // Final color\n"
" vec3 base_rock = rock_color * (0.8 + detail * 0.4);\n"
" vec3 base_grass = grass_color * (0.85 + detail * 0.3);\n"
" vec3 base_dirt = dirt_color * (0.85 + detail * 0.3);\n"
" vec3 color = base_rock;\n"
" color = mix(color, base_dirt, dirt_t);\n"
" color = mix(color, base_grass, grass_t);\n"
"\n"
" // LOD color override (set by debug_lod_colors)\n"
" if (use_lod_color) {\n"
" color = lod_color;\n"
" }\n"
"\n"
" // Route 2 texturing (T1 debug): imported Quake brush faces carry a\n"
" // material_id in COLOR.r (id/255), tagged COLOR = (r,0,0,1). Give each\n"
" // material a distinct flat color to validate the channel end-to-end\n"
" // (WAD atlas + planar UV reprojection replace this next). The g<0.5 &&\n"
" // b<0.5 guard rejects the default white COLOR of no-vertex-color chunks.\n"
" vec2 pbr_uv = vec2(0.0); float pbr_layer = 0.0; bool pbr_valid = false;\n"
" vec2 pbr_ddx = vec2(0.0); vec2 pbr_ddy = vec2(0.0);\n" // LOD gradients of the CONTINUOUS uv (pre-fract)
" vec3 pbr_T = vec3(1.0,0.0,0.0); vec3 pbr_B = vec3(0.0,1.0,0.0);\n"
" if (v_is_brush > 0.5) {\n"
" float mid = v_mat * 255.0;\n"
" if (use_textures) {\n"
" int prow = int(v_proj + 0.5) - 1;\n" // candidate 0 → row (1-based); <0 = none
" int prow1 = int(v_proj2 + 0.5) - 1;\n" // candidate 1 (crease other face)
" if (has_projection_table && prow1 >= 0 && prow1 < projection_rows && prow >= 0) {\n"
" // Sharp-crease fix: pick the candidate whose face best matches this pixel's\n"
" // normal, so each side of the crease textures with its OWN face. Match against\n"
" // the face's GEOMETRIC normal (texel3), NOT cross(u_axis,v_axis): Quake projects\n"
" // diagonal faces with AXIAL texture axes, so cross(u,v) is axial and mis-picks\n"
" // the arch faces (oracle: 77.5%% -> 89.8%% on START.MAP diagonals).\n"
" vec3 na = texelFetch(projection_table, ivec2(3, prow), 0).xyz;\n"
" vec3 nb = texelFetch(projection_table, ivec2(3, prow1), 0).xyz;\n"
" float sa = abs(dot(na, world_normal));\n"
" float sb = abs(dot(nb, world_normal));\n"
" if (sb > sa) prow = prow1;\n"
" }\n"
" if (has_projection_table && prow >= 0 && prow < projection_rows) {\n"
" // Stage 2: faithful per-FACE Quake projection. Row = 3 texels:\n"
" // (layer,u_off,v_off,0)(u_axis.xyz,0)(v_axis.xyz,0), axes already ÷texW/texH.\n"
" vec4 t0 = texelFetch(projection_table, ivec2(0, prow), 0);\n"
" vec4 t1 = texelFetch(projection_table, ivec2(1, prow), 0);\n"
" vec4 t2 = texelFetch(projection_table, ivec2(2, prow), 0);\n"
" vec2 raw = vec2(dot(t1.xyz, world_pos) + t0.g, dot(t2.xyz, world_pos) + t0.b);\n"
" vec2 ddx = dFdx(raw); vec2 ddy = dFdy(raw);\n" // LOD from CONTINUOUS uv, not fract() (kills seam-glint)
" vec2 uv = fract(raw);\n"
" color = textureGrad(tex_atlas, vec3(uv, t0.r), ddx, ddy).rgb;\n"
" pbr_uv = uv; pbr_layer = t0.r; pbr_valid = true;\n"
" pbr_ddx = ddx; pbr_ddy = ddy;\n"
" pbr_T = t1.xyz; pbr_B = t2.xyz;\n"
" } else {\n"
" // Axial fallback (Stage 1: world-aligned, per-material) when a cell has\n"
" // no per-face projection (soft/edge cells, non-imported convex brushes).\n"
" vec3 qn = vec3(world_normal.x, -world_normal.z, world_normal.y);\n"
" int bi = 0; float best = -2.0;\n"
" for (int i = 0; i < 6; i++) { float d = dot(qn, QN[i]); if (d > best) { best = d; bi = i; } }\n"
" vec3 gu = vec3(QU[bi].x, QU[bi].z, -QU[bi].y);\n" // _q2g(quake u axis)
" vec3 gv = vec3(QV[bi].x, QV[bi].z, -QV[bi].y);\n"
" vec2 raw = vec2(dot(gu, world_pos), dot(gv, world_pos)) / tex_tile;\n"
" vec2 ddx = dFdx(raw); vec2 ddy = dFdy(raw);\n"
" float layer = max(floor(mid + 0.5) - 1.0, 0.0);\n" // material_id is 1-based
" color = textureGrad(tex_atlas, vec3(fract(raw), layer), ddx, ddy).rgb;\n"
" pbr_uv = fract(raw); pbr_layer = layer; pbr_valid = true;\n"
" pbr_ddx = ddx; pbr_ddy = ddy;\n"
" pbr_T = gu; pbr_B = gv;\n"
" }\n"
" } else if (mid > 150.0) { color = vec3(1.0, 0.0, 0.0); }\n" // DEBUG: capped cell
" else { color = fract(vec3(mid * 0.13, mid * 0.37, mid * 0.61)) * 0.7 + 0.15; }\n"
" }\n"
"\n"
" ALBEDO = color;\n"
" ROUGHNESS = 0.85 + tri_noise * 0.15; // noise-driven roughness\n"
" SPECULAR = 0.3;\n"
" METALLIC = 0.0;\n"
// Under path tracing this runs once per BOUNCE per sample, so these atlas
// fetches multiply by the bounce count. An indirect bounce reaches the
// image only through the throughput, where roughness detail and a normal
// map are not distinguishable — but GLOW is, because it emits the light
// the bounce exists to gather, so that one stays unconditional.
//
// PRIMARY_HIT is an RT-only built-in (SceneShaderRaytracing renames), and
// #ifdef RT is defined by ShaderPreprocessor only for the RT variant
// (material_storage.cpp), so the rasterizer still compiles the full path
// and is byte-identical to before.
" bool pbr_full = true;\n"
"#ifdef RT\n"
" pbr_full = PRIMARY_HIT;\n"
"#endif\n"
" if (use_pbr && pbr_valid) {\n"
// One fetch, both channels: rgb is the glow, a is the gloss.
" vec4 gr = textureGrad(glow_atlas, vec3(pbr_uv, pbr_layer), pbr_ddx, pbr_ddy);\n"
" EMISSION = gr.rgb;\n"
" if (pbr_full) {\n"
" ROUGHNESS = 1.0 - gr.a * 0.7;\n" // gloss=specular: matte base, sharp where glossy
" vec3 Nn = normalize(world_normal);\n"
" vec3 Tn = normalize(pbr_T);\n" // u_axis (VectorS) — normalize: proj table divides by texW
" vec3 Bn = normalize(pbr_B);\n" // v_axis (VectorT)
" vec3 sn = normalize(textureGrad(norm_atlas, vec3(pbr_uv, pbr_layer), pbr_ddx, pbr_ddy).rgb - 0.5);\n" // DarkPlaces decode
" sn.xy *= normal_strength;\n"
" vec3 wn = normalize(sn.x * Tn + sn.y * Bn + sn.z * Nn);\n" // DP: sn.x*VectorS + sn.y*VectorT + sn.z*VectorR (no flip; +v_axis == +V)
" NORMAL = normalize((VIEW_MATRIX * vec4(wn, 0.0)).xyz);\n"
" }\n"
" }\n"
"}\n"
);
// Workers are created in NOTIFICATION_ENTER_TREE (which fires before
// _ready and persists across editor tab switches). Do NOT create them
// here — _ready() only fires once but ENTER_TREE handles lifecycle.
}
Dictionary DCTerrain::get_scalar_query_counts() const {
Dictionary d;
d["sample_density"] = (int64_t)n_sample_density_.load(std::memory_order_relaxed);
d["eval_with_edits"] = (int64_t)n_eval_with_edits_.load(std::memory_order_relaxed);
d["collision_mode"] = (int)collision_mode_;
return d;
}
void DCTerrain::reset_scalar_query_counts() {
n_sample_density_.store(0, std::memory_order_relaxed);
n_eval_with_edits_.store(0, std::memory_order_relaxed);
}
void DCTerrain::_process(double delta) {
// DC_SCALAR_PROFILE=1 prints the scalar query rate once a second. Off by
// default and env-gated rather than a property, so measuring costs no game
// script change and normal runs get no extra log line. #64 NEXT 3.
if (scalar_profile_accum_ == 0.0) {
scalar_profile_on_ = OS::get_singleton()->get_environment("DC_SCALAR_PROFILE") == "1";
}
if (scalar_profile_on_) {
scalar_profile_accum_ += delta;
if (scalar_profile_accum_ >= 1.0) {
UtilityFunctions::print(
"DC_SCALAR_PROFILE ", (double)scalar_profile_accum_, "s: sample_density=",
(int64_t)n_sample_density_.load(std::memory_order_relaxed),
" eval_with_edits=", (int64_t)n_eval_with_edits_.load(std::memory_order_relaxed),
" collision_mode=", (int)collision_mode_);
scalar_profile_accum_ = 0.0001;
}
}
if (!is_inside_tree()) return;
frame_counter_++;
// Deferred worker spawn: the pool was held back at ENTER_TREE so the GPU
// thread's create_local_rendering_device() could have the cores to itself
// (measured 30.2 s contended vs 4.17 s free). Spawn the frame it lands.
// The 90 s escape hatch keeps a GPU that never initialises from producing a
// terrain-less world — we lose the GPU mesher, not the terrain.
if (workers_deferred_) {
double waited = std::chrono::duration<double>(
std::chrono::steady_clock::now() - workers_deferred_since_).count();
bool ready = gpu_thread_ && gpu_thread_->is_available();
if (ready || waited > 90.0) {
workers_deferred_ = false;
if (debug_logging_ || !ready) {
fprintf(stderr, "[GPU-FIRST] deferred workers %.2f s (gpu_available=%d)\n",
waited, (int)ready);
fflush(stderr);
}
_spawn_workers();
}
}
// 1. Get camera position
float cam_pos[3] = {0.0f, 0.0f, 0.0f};
bool has_camera = false;
Camera3D* cam = nullptr;
// In editor mode, ALWAYS use editor viewport camera (not the scene's Camera3D)
if (Engine::get_singleton()->is_editor_hint()) {
EditorInterface* ei = EditorInterface::get_singleton();
if (ei) {
SubViewport* editor_vp = ei->get_editor_viewport_3d(0);
if (editor_vp) {
cam = editor_vp->get_camera_3d();
}
}
} else {
// Game mode: use the viewport camera
Viewport* vp = get_viewport();
if (vp) {
cam = vp->get_camera_3d();
}
}
if (cam) {
Vector3 gp = cam->get_global_position();
cam_pos[0] = gp.x;
cam_pos[1] = gp.y;
cam_pos[2] = gp.z;
has_camera = true;
// Update camera direction for frustum-aware LOD
Vector3 fwd = -cam->get_global_basis().get_column(2); // -Z is forward
octree_clipmap_.cam_forward[0] = fwd.x;
octree_clipmap_.cam_forward[1] = fwd.y;
octree_clipmap_.cam_forward[2] = fwd.z;
// Update screen-space error metric parameters from camera
octree_clipmap_.camera_fov_rad = Math::deg_to_rad(cam->get_fov());
Viewport* cam_vp = cam->get_viewport();
if (cam_vp) {
octree_clipmap_.viewport_height = static_cast<float>(
cam_vp->get_visible_rect().size.y);
}
}
// Debug: log status on first frames, then every 60 frames for the WHOLE run.
// The `debug_frames_ < 20` cap used to stop this line ~39 s in, which meant
// the resident-set counters (leaves / blocked / dealloc_q) went dark for
// exactly the part of a session where they matter — cost that grows with
// accumulated world rather than with what the camera is doing right now is
// invisible without them (2026-09-03).
if (debug_logging_ && (debug_frames_ < 5 || frame_counter_ % 60 == 0)) {
auto stats = octree_clipmap_.get_stats();
const char* mode_str = (lod_mode_ == LOD_BLOCKING_SWAP) ? "BLOCKING" : "EAGER";
UtilityFunctions::print(String("DCTerrain[{0}]: mode={1} cam={2} pos=({3},{4},{5}) leaves={6} pending={7} blocked={8} dealloc_q={9} workers={10}").format(
Array::make(debug_frames_, mode_str, has_camera,
cam_pos[0], cam_pos[1], cam_pos[2],
stats.leaf_count, stats.pending_count,
stats.blocked_count,
static_cast<int>(dealloc_queue_.size()),
using_editor_worker_ ? (s_editor_worker_ ? 1 : 0) : static_cast<int>(workers_.size()))));
debug_frames_++;
}
// --- Camera movement detection ---
// Dead-zone: skip LOD re-evaluation if camera barely moved.
// This prevents infinite re-traversal when the camera is stationary.
float cam_speed_sq = 0.0f;
if (has_camera && frame_counter_ > 1) {
float dx = cam_pos[0] - prev_cam_pos_[0];
float dy = cam_pos[1] - prev_cam_pos_[1];
float dz = cam_pos[2] - prev_cam_pos_[2];
cam_speed_sq = dx * dx + dy * dy + dz * dz;
// Camera moved past dead-zone — re-traverse LOD
float dead_zone = voxel_size_ * 0.5f;
if (cam_speed_sq > dead_zone * dead_zone) {
octree_clipmap_.invalidate_settled();
}
}
if (has_camera) {
prev_cam_pos_[0] = cam_pos[0];
prev_cam_pos_[1] = cam_pos[1];
prev_cam_pos_[2] = cam_pos[2];
}
// Speed-aware merge throttle: reduce merges when camera is moving fast
float finest_cell = voxel_size_ * 14.0f; // LOD0 cell world size
float fast_threshold_sq = finest_cell * finest_cell * 4.0f; // 2x cell size
bool camera_moving_fast = cam_speed_sq > fast_threshold_sq;
if (camera_moving_fast) {
// Temporarily reduce merges to prevent freeing in-flight nodes
octree_clipmap_.max_merges_per_frame = 1;
} else {
octree_clipmap_.max_merges_per_frame = 8;
}
// Clear freed-node tracker from previous frame
freed_nodes_.clear();
// --- Frame timing ---
auto frame_ms = [](auto t0, auto t1) {
return std::chrono::duration<double, std::milli>(t1 - t0).count();
};
auto t_frame = std::chrono::high_resolution_clock::now();
// 2. Traverse octree (unless LOD frozen)
// Burst split budget for first 10 frames (or after rebuild) to populate all octants
// uniformly at spawn. Normal budget (16) can starve far-side octants via stack bias.
if (frame_counter_ <= 30) {
octree_clipmap_.max_splits_per_frame = 256;
} else {
octree_clipmap_.max_splits_per_frame = 16;
}
if (!lod_frozen_) {
// Incremental re-center: shift root by one child-span when camera
// moves beyond half a child-span from center. Transplants overlapping
// children, frees non-overlapping, creates new PENDING at edges.
if (has_camera) {
int child_span = lod_voxel_span(octree_clipmap_.voxels_per_cell,
octree_clipmap_.max_lod_levels - 2);
int threshold = child_span / 2;
Vector3i cam_voxel(
static_cast<int>(std::floor(cam_pos[0] / voxel_size_)),
static_cast<int>(std::floor(cam_pos[1] / voxel_size_)),
static_cast<int>(std::floor(cam_pos[2] / voxel_size_)));
Vector3i center = octree_clipmap_.get_root_center();
Vector3i delta(cam_voxel.x - center.x, cam_voxel.y - center.y, cam_voxel.z - center.z);
// Shift one child-span at a time per axis
Vector3i shift(0, 0, 0);
if (delta.x > threshold) shift.x = child_span;
else if (delta.x < -threshold) shift.x = -child_span;
if (delta.y > threshold) shift.y = child_span;
else if (delta.y < -threshold) shift.y = -child_span;
if (delta.z > threshold) shift.z = child_span;
else if (delta.z < -threshold) shift.z = -child_span;
if (shift.x != 0 || shift.y != 0 || shift.z != 0) {
Vector3i new_center(center.x + shift.x, center.y + shift.y, center.z + shift.z);
octree_clipmap_.recenter_incremental(new_center, frame_counter_);
generation_++;
for (auto& w : workers_) w->set_generation(generation_);
// Reset stuck pre-mesh entries: recenter invalidates in-flight
// requests (stale generation), so MESH_GENERATING entries will
// never receive results. Reset them to MESH_PENDING for redispatch.
auto& cache = octree_clipmap_.pre_mesh_cache_ref();
for (auto& [morton, entry] : cache) {
if (entry.state == OctreeClipmapNode::MESH_GENERATING) {
entry.state = OctreeClipmapNode::MESH_PENDING;
}
}
// FROZEN-CHUNK FIX: the generation_++ above also strands octree
// NODES that were in GENERATING — their in-flight results are
// dropped at the global-generation check (req_gen != cur_gen) and
// mark_pending() refuses to re-enqueue a GENERATING node, so they
// sit in GENERATING forever: never ACTIVE, never split -> permanent
// frozen coarse chunks. Force-requeue them (requeue_pending bypasses
// the state guard — its documented purpose), mirroring the edit
// path's per-node fix. Only live/transplanted leaves remain here;
// non-transplanted subtrees were already freed by recenter.
auto stranded_leaves = octree_clipmap_.get_leaves();
int restranded = 0;
for (auto* node : stranded_leaves) {
if (node->state == OctreeClipmapNode::GENERATING) {
octree_clipmap_.requeue_pending(node);
restranded++;
}
}
if (restranded > 0) {
octree_clipmap_.invalidate_settled();
UtilityFunctions::print("[LOD] recenter re-queued ", restranded,
" stranded GENERATING nodes (frozen-chunk fix)");
}
}
}
octree_clipmap_.traverse(cam_pos, frame_counter_);
}
if (lod_mode_ == LOD_BLOCKING_SWAP) {
// BLOCKING_SWAP: apply already-completed cached meshes here (consumes
// results from past frames' pre-mesh dispatches; doesn't issue new work).
// Pre-mesh DISPATCH is deferred to after the regular pending dispatch
// loop below, so user-initiated edits get worker queue slots first.
_apply_cached_meshes();
// Stale parent cleanup (parent mesh hidden after children uploaded)
octree_clipmap_.check_stale_parents();
} else {
// EAGER_FADE: check if any split-in-progress parents can now be freed
// (all children became ACTIVE since the split)
octree_clipmap_.check_stale_parents();
}
// DIAGNOSTIC (ungated, throttled, self-silencing): the "coarse LOD doesn't leave —
// yellow over green" bug. An INTERNAL (already-split) node that still owns a valid
// mesh_instance is literally a coarse mesh drawn over its finer children. Report
// any such node and WHY it wasn't freed: its parent_mesh_stale flag (false = the
// flag was cleared without freeing = leak; true = free blocked) and the first
// child that fails the free gate (state / fade). Silent once the tree is clean.
if ((frame_counter_ % 60) == 0 && octree_clipmap_.get_root()) {
std::vector<OctreeClipmapNode*> stk;
stk.push_back(octree_clipmap_.get_root());
int leaked = 0;
int col_leaked = 0;
while (!stk.empty()) {
OctreeClipmapNode* n = stk.back(); stk.pop_back();
if (!n) continue;
if (!n->is_leaf()) {
// COLLISION, WHICH THIS DIAGNOSTIC NEVER CHECKED.
//
// It reported internal nodes still DRAWING a coarse mesh and
// said nothing about internal nodes still COLLIDING with one --
// and the collision case is the worse of the two, because it is
// invisible. _on_mesh_free deliberately keeps a parent's
// collision alive while its children mesh, so a parent that
// never passes the Stage 2 gate leaves a coarse surface, 2
// voxels of overlap wide, sitting above the fine one you can
// see. A capsule between them collects contacts from both,
// barely penetrates either, and gets ejected: measured 5.64
// contacts per frame on ground whose steepest triangle is 11.8
// degrees, with a contact normal at 168.8 degrees -- a ceiling,
// on ground with no overhang anywhere in the visible mesh.
//
// debug_collision_faces cannot see it either: it walks
// get_leaves(), and a node that has split is not a leaf.
if (n->collision_body.is_valid()) {
int cbad = -1, cbstate = -1; float cbfade = -1.0f;
for (int i = 0; i < 8; i++) {
OctreeClipmapNode* c = n->children[i];
bool ok = c && (c->state == OctreeClipmapNode::ACTIVE ||
c->state == OctreeClipmapNode::EMPTY) && c->fade_t >= 0.99f;
if (!ok) { cbad = i; cbstate = c ? (int)c->state : -1; cbfade = c ? c->fade_t : -1.0f; break; }
}
if (col_leaked < 8) {
UtilityFunctions::print("[STALECOL] INTERNAL lod=", n->lod_level,
" still COLLIDING pos=(", n->position.x, ",", n->position.y, ",", n->position.z,
") parent_collision_stale=", n->parent_collision_stale,
" blockChild=", cbad, " childState=", cbstate, " childFade=", cbfade);
}
col_leaked++;
}
if (n->mesh_instance.is_valid() || n->seam_mesh_instance.is_valid()) {
int bad = -1, bstate = -1; float bfade = -1.0f;
for (int i = 0; i < 8; i++) {
OctreeClipmapNode* c = n->children[i];
bool ok = c && (c->state == OctreeClipmapNode::ACTIVE ||
c->state == OctreeClipmapNode::EMPTY) && c->fade_t >= 0.99f;
if (!ok) { bad = i; bstate = c ? (int)c->state : -1; bfade = c ? c->fade_t : -1.0f; break; }
}
if (leaked < 8) {
UtilityFunctions::print("[STALEDIAG] INTERNAL lod=", n->lod_level,
" still-drawing mesh=", n->mesh_instance.is_valid(),
" seam=", n->seam_mesh_instance.is_valid(),
" parent_mesh_stale=", n->parent_mesh_stale,
" blockChild=", bad, " childState=", bstate, " childFade=", bfade);
}
leaked++;
}
for (int i = 0; i < 8; i++) if (n->children[i]) stk.push_back(n->children[i]);
}
}
if (leaked > 0) {
UtilityFunctions::print("[STALEDIAG] ", leaked,
" internal nodes still drawing a coarse mesh/seam over their children");
}
if (col_leaked > 0) {
UtilityFunctions::print("[STALECOL] ", col_leaked,
" internal nodes still COLLIDING with a coarse surface over their children");
}
}
// NOTE: Stale parent shadow disable was removed — disabling shadows on
// parents while children also have shadows off (during fade-in) causes
// black flickering. Instead, children get shadows enabled immediately
// (see _upload_cell change below) so both parent and child cast shadows
// during transition. Minor double-shadow is less visible than no shadow.
// Animate fade_t on leaves for dithered screen-door transitions.
// Per-chunk ShaderMaterial with regular uniform (NOT instance uniform) —
// avoids Godot's instance uniform buffer overflow entirely.
// Skip entirely in BLOCKING_SWAP mode — no fading, visibility is binary.
if (lod_mode_ != LOD_BLOCKING_SWAP)
{
static const Color LOD_COLORS[] = {
Color(0.2f, 0.8f, 0.2f), Color(0.2f, 0.6f, 0.8f),
Color(0.8f, 0.8f, 0.2f), Color(0.8f, 0.4f, 0.1f),
Color(0.8f, 0.2f, 0.2f), Color(0.6f, 0.2f, 0.8f),
Color(0.2f, 0.2f, 0.8f), Color(0.8f, 0.6f, 0.8f),
Color(0.4f, 0.4f, 0.4f), Color(1.0f, 1.0f, 1.0f),
Color(0.5f, 0.3f, 0.1f), Color(0.1f, 0.5f, 0.5f),
};
// Velocity-boosted fade speed — fast camera movement accelerates transitions
float effective_fade_speed = fade_speed_;
if (cam_speed_sq > 0.0f) {
float cam_speed = std::sqrt(cam_speed_sq);
float finest_cell_size = voxel_size_ * static_cast<float>(octree_clipmap_.voxels_per_cell);
float speed_ratio = cam_speed / (finest_cell_size * static_cast<float>(delta));
if (speed_ratio > 1.0f) {
effective_fade_speed = fade_speed_ * std::min(speed_ratio, 10.0f);
}
}
RenderingServer* rs_fade = RenderingServer::get_singleton();
auto leaves = octree_clipmap_.get_leaves();
for (auto* leaf : leaves) {
if (!leaf->mesh_instance.is_valid()) continue;
float diff = leaf->fade_target - leaf->fade_t;
if (std::fabs(diff) > 0.001f) {
// Actively fading — animate fade_t
float dir = (diff > 0.0f) ? 1.0f : -1.0f;
leaf->fade_t += dir * effective_fade_speed * static_cast<float>(delta);
if (dir > 0.0f) {
if (leaf->fade_t > leaf->fade_target)
leaf->fade_t = leaf->fade_target;
} else {
if (leaf->fade_t < leaf->fade_target)
leaf->fade_t = leaf->fade_target;
}
// Create per-chunk ShaderMaterial on first fade frame
auto it = fade_overrides_.find(leaf);
if (it == fade_overrides_.end()) {
auto mesh_it = node_meshes_.find(leaf);
if (mesh_it == node_meshes_.end() || mesh_it->second.is_null()) continue;
Ref<ShaderMaterial> mat;
mat.instantiate();
mat->set_shader(terrain_shader_);
_apply_texture_params(mat);
int lod = leaf->lod_level;
if (lod < 0) lod = 0;
if (lod >= MAX_LOD_MATERIALS) lod = MAX_LOD_MATERIALS - 1;
Color c = debug_lod_colors_ ? LOD_COLORS[lod < (int)(sizeof(LOD_COLORS)/sizeof(LOD_COLORS[0]))
? lod : (int)(sizeof(LOD_COLORS)/sizeof(LOD_COLORS[0])) - 1] : terrain_color_;
mat->set_shader_parameter("use_lod_color", debug_lod_colors_);
mat->set_shader_parameter("lod_color",
Vector3(c.r, c.g, c.b));
mat->set_shader_parameter("fade_t", leaf->fade_t);
mat->set_shader_parameter("normal_perturbation", normal_perturbation_);
// Set on ArrayMesh surface
mesh_it->second->surface_set_material(0, mat);
fade_overrides_[leaf] = mat;
} else {
// Update fade_t on existing per-chunk ShaderMaterial
it->second->set_shader_parameter("fade_t", leaf->fade_t);
}
} else {
// Fade complete — restore shared lod material, free per-chunk override
auto it = fade_overrides_.find(leaf);
if (it != fade_overrides_.end()) {
auto mesh_it = node_meshes_.find(leaf);
if (mesh_it != node_meshes_.end() && mesh_it->second.is_valid()) {
int lod = leaf->lod_level;
if (lod < 0) lod = 0;
if (lod >= MAX_LOD_MATERIALS) lod = MAX_LOD_MATERIALS - 1;
mesh_it->second->surface_set_material(0, lod_materials_[lod]);
}
// Re-enable shadow casting now that fade is complete
if (rs_fade && leaf->mesh_instance.is_valid()) {
rs_fade->instance_geometry_set_cast_shadows_setting(
leaf->mesh_instance,
RenderingServer::SHADOW_CASTING_SETTING_ON);
}
fade_overrides_.erase(it);
}
}
}
// Clean up fade overrides for nodes that are no longer leaves
// (they split while fading, fade_t was forced to 1.0 in _split_node)
for (auto it = fade_overrides_.begin(); it != fade_overrides_.end(); ) {
if (!it->first->is_leaf()) {
auto mesh_it = node_meshes_.find(it->first);
if (mesh_it != node_meshes_.end() && mesh_it->second.is_valid()) {
int lod = it->first->lod_level;
if (lod < 0) lod = 0;
if (lod >= MAX_LOD_MATERIALS) lod = MAX_LOD_MATERIALS - 1;
mesh_it->second->surface_set_material(0, lod_materials_[lod]);
}
it = fade_overrides_.erase(it);
} else {
++it;
}
}
}
auto t_traverse = std::chrono::high_resolution_clock::now();
// 3. Dispatch pending nodes to workers (priority: closest to camera first)
// Skip dispatch if workers haven't been created (editor mode skips worker init).
int regular_dispatched_this_frame = 0;
int edits_dispatched_this_frame = 0; // routed through the priority lane (1b)
auto pending = octree_clipmap_.drain_pending();
if (!_has_workers()) {
// Re-queue everything so it's not lost.
// drain_pending() pulled these out without resetting state, so use
// requeue_pending which bypasses the "already PENDING" guard.
for (auto* node : pending) {
octree_clipmap_.requeue_pending(node);
}
} else {
if (has_camera && pending.size() > 1) {
std::sort(pending.begin(), pending.end(),
[&cam_pos, this](OctreeClipmapNode* a, OctreeClipmapNode* b) {
// User edits always dispatch before speculative LOD streaming (1b).
if (a->edit_dirty != b->edit_dirty) return a->edit_dirty;
// Manhattan distance to camera — lower LOD + closer = higher priority
auto dist = [&](OctreeClipmapNode* n) {
float lv = lod_voxel_size(voxel_size_, n->lod_level);
float cs = 14.0f * lv;
float cx = n->position.x * voxel_size_ + cs * 0.5f;
float cy = n->position.y * voxel_size_ + cs * 0.5f;
float cz = n->position.z * voxel_size_ + cs * 0.5f;
return std::fabs(cam_pos[0] - cx) + std::fabs(cam_pos[1] - cy) + std::fabs(cam_pos[2] - cz);
};
return dist(a) < dist(b);
});
}
for (auto* node : pending) {
CellRequest req;
req.node = node;
float lod_voxel = lod_voxel_size(voxel_size_, node->lod_level);
int overlap = 2; // 2 voxels overlap on each side
int grid_size = 32; // power-of-2 grid (8x GPU occupancy vs 16)
int nominal = grid_size - 2 * overlap; // 28 owned voxels per cell
float nominal_ws = static_cast<float>(nominal) * lod_voxel;
// Nominal origin: the region this cell "owns"
req.nominal_origin[0] = node->position.x * voxel_size_;
req.nominal_origin[1] = node->position.y * voxel_size_;
req.nominal_origin[2] = node->position.z * voxel_size_;
req.nominal_size = nominal_ws;
// Grid origin: shift back by overlap so nominal region sits in the middle
req.cell_origin[0] = req.nominal_origin[0] - overlap * lod_voxel;
req.cell_origin[1] = req.nominal_origin[1] - overlap * lod_voxel;
req.cell_origin[2] = req.nominal_origin[2] - overlap * lod_voxel;
req.voxel_size = lod_voxel;
req.grid_dims = grid_size + 1; // 17 (stride for 16-cell grid)
req.voxels_per_cell = grid_size; // 16 (actual cells, power-of-2)
req.overlap = overlap;
req.node_position = node->position;
req.lod_level = node->lod_level;
req.density = &density_;
req.generator = generator_ptr_;
req.gpu_eligible = config_.use_gpu && generator_ptr_ != nullptr && generator_ptr_->has_gpu_shader();
req.use_adaptive_qef = use_adaptive_qef_;
req.use_shortest_diagonal = use_shortest_diagonal_;
req.use_mesh_simplify = use_mesh_simplify_;
// Cull edits to only those overlapping this chunk's AABB.
// At 10K edits, a typical chunk overlaps 0-5 — massive speedup.
{
float chunk_span = static_cast<float>(grid_size) * lod_voxel;
float cmin[3] = { req.cell_origin[0], req.cell_origin[1], req.cell_origin[2] };
float cmax[3] = { cmin[0] + chunk_span, cmin[1] + chunk_span, cmin[2] + chunk_span };
req.edits = cull_edits_for_chunk(config_.edits, cmin, cmax);
}
req.generation = generation_;
req.node_generation = node->generation;
req.submit_time_us = std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count();
if (req.edits && !req.edits->empty() && debug_logging_) {
UtilityFunctions::print("[DISPATCH] chunk lod=", node->lod_level,
" edits=", (int)req.edits->size(),
" (of ", config_.edits ? (int)config_.edits->size() : 0, ")",
" gen=", req.generation);
}
// User edits take the worker's high-priority lane so they never wait
// behind a burst of queued LOD/pre-mesh chunks (1b).
const bool is_edit = node->edit_dirty;
const bool submitted = is_edit ? _submit_to_worker_priority(req)
: _submit_to_worker(req);
if (submitted) {
node->state = OctreeClipmapNode::GENERATING;
node->edit_dirty = false; // consumed — dispatched into its lane
// Consumed on the SAME terms as edit_dirty: this dispatch reads the
// current config_.edits (cull_edits_for_chunk above), so the follow-up
// it was asking for is the request being submitted right now. Leaving
// it set would re-queue this node again when the result lands, forever.
node->remesh_after_upload = false;
regular_dispatched_this_frame++;
if (is_edit) edits_dispatched_this_frame++;
if (debug_logging_) {
UtilityFunctions::print("[LIFE] DISP", is_edit ? "(EDIT)" : "", " chunk=(", node->position.x, ",", node->position.y, ",", node->position.z,
") lod=", node->lod_level, " node_gen=", node->generation,
" edits=", (int)(req.edits ? req.edits->size() : 0));
}
} else {
// Queue full — force re-enqueue so it's retried next frame.
// drain_pending() pulled this node out without resetting state from
// PENDING, so plain mark_pending() would no-op via its guard. Use
// requeue_pending which bypasses the guard. Without this, the node
// is orphaned: stuck in PENDING state but not in the queue, and
// edit_terrain_box's state filter excludes PENDING, so subsequent
// edits silently skip it.
octree_clipmap_.requeue_pending(node);
if (debug_logging_) {
UtilityFunctions::print("[LIFE] DISP_FAIL_QUEUE_FULL chunk=(", node->position.x, ",", node->position.y, ",", node->position.z,
") lod=", node->lod_level);
}
}
}
} // end else (workers_ not empty)
// Pre-mesh dispatch runs AFTER regular pending dispatch so edit/streaming
// chunks claim any available worker queue slots first. Pre-mesh is
// speculative LOD refinement and takes whatever capacity is left.
// Pass the regular-dispatched count so pre-mesh can adapt its per-frame
// cap: yield when regular work was active, run faster when system is idle.
// Suppress speculative pre-mesh while user edits are in flight (dispatched
// this frame or still queued in a worker's priority lane) so workers stay
// focused on the edit. Resumes automatically once the priority lanes drain (1b).
// FAIR-SHARE (was `&& !edits_active`): pre-mesh LOD refinement now runs EVEN while
// edits are in flight. Edits keep worker priority regardless — they go to the
// PRIORITY lane (_submit_to_worker_priority above) which workers drain before the
// NORMAL lane that pre-mesh uses; and _dispatch_pre_mesh_requests self-caps at
// TARGET_QUEUE_DEPTH while still guaranteeing MIN_PREMESH_PER_FRAME. So edits stay
// responsive but LOD makes guaranteed forward progress. The old `!edits_active`
// kill-switch dropped pre-mesh to ZERO for the ENTIRE duration of any edit backlog
// (the 476-edit spawn replay + the 3s state-poll re-feed), which starved near-edit
// LOD refinement for 1-2 minutes — the verified root cause of the "coarse chunks
// near edits never refine" stall.
const bool edits_active = edits_dispatched_this_frame > 0 || _worker_priority_depth() > 0;
if (lod_mode_ == LOD_BLOCKING_SWAP) {
_dispatch_pre_mesh_requests(regular_dispatched_this_frame);
if (edits_active && debug_logging_) {
UtilityFunctions::print("[LOD] fair-share: pre-mesh running alongside edits in flight");
}
}
auto t_dispatch = std::chrono::high_resolution_clock::now();
// 4. Poll results from workers — time-budgeted upload
int uploaded = 0;
if (_has_workers()) {
_poll_workers([&](CellResult& result) {
// Discard stale results from previous generations
if (result.generation != generation_) {
if (debug_logging_ && result.node) {
UtilityFunctions::print("[LIFE] DROP reason=global_gen_mismatch chunk=(",
result.node->position.x, ",", result.node->position.y, ",", result.node->position.z,
") lod=", result.node->lod_level,
" req_gen=", result.generation, " cur_gen=", generation_);
}
return;
}
// Pre-mesh result: store in cache, don't upload
if (result.is_pre_mesh) {
auto& cache = octree_clipmap_.pre_mesh_cache_ref();
auto it = cache.find(result.pre_mesh_morton);
if (it != cache.end() && (it->second.state == OctreeClipmapNode::MESH_PENDING ||
it->second.state == OctreeClipmapNode::MESH_GENERATING)) {
it->second.state = OctreeClipmapNode::MESH_UP_TO_DATE;
it->second.data.mesh_data = std::move(result.mesh_data);
it->second.data.seam_nodes = std::move(result.seam_nodes);
it->second.data.active_voxel_count = result.active_voxel_count;
it->second.data.triangle_count = result.triangle_count;
it->second.data.geometric_error = result.geometric_error;
octree_clipmap_.invalidate_settled();
}
return;
}
if (result.success && result.node &&
freed_nodes_.find(result.node) == freed_nodes_.end() &&
result.node->generation == result.node_generation) {
_upload_cell(result);
uploaded++;
// Node state changed — may affect split/merge decisions
octree_clipmap_.invalidate_settled();
// 1c: an edit that landed while this chunk was generating left
// edit_dirty set (its in-flight result just uploaded with the
// older edit prefix). Re-queue exactly once so it remeshes with
// the latest edits — guarantees forward progress under rapid
// carving instead of livelocking on generation bumps.
// remesh_after_upload is the same guarantee for a NON-interactive
// edit (the §5a.2 level shell), which must not enter the priority
// lane but must still remesh — see OctreeClipmapNode.
if (result.node->edit_dirty || result.node->remesh_after_upload) {
octree_clipmap_.mark_pending(result.node);
}
if (debug_logging_) {
int64_t now_us = std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count();
int64_t total_us = now_us - result.submit_time_us;
int64_t poll_wait_us = now_us - result.result_enqueue_us;
UtilityFunctions::print("[LIFE] UPLD chunk=(",
result.node->position.x, ",", result.node->position.y, ",", result.node->position.z,
") lod=", result.node->lod_level, " node_gen=", result.node->generation,
" tris=", result.triangle_count,
" | total_ms=", total_us / 1000.0,
" qwait_ms=", result.queue_wait_us / 1000.0,
" compute_ms=", result.compute_us / 1000.0,
" pollwait_ms=", poll_wait_us / 1000.0);
}
} else if (debug_logging_) {
const char* reason = "unknown";
if (!result.success) reason = "worker_failed";
else if (!result.node) reason = "null_node";
else if (freed_nodes_.find(result.node) != freed_nodes_.end()) reason = "node_freed";
else if (result.node->generation != result.node_generation) reason = "node_gen_mismatch";
if (result.node) {
UtilityFunctions::print("[LIFE] DROP reason=", reason,
" chunk=(", result.node->position.x, ",", result.node->position.y, ",", result.node->position.z,
") lod=", result.node->lod_level,
" req_node_gen=", result.node_generation,
" cur_node_gen=", result.node->generation);
} else {
UtilityFunctions::print("[LIFE] DROP reason=", reason, " (no node)");
}
}
}, 6.0, 4);
}
auto t_upload = std::chrono::high_resolution_clock::now();
// 4b. Seam mesh generation — process nodes waiting for neighbors
int seams_built = 0;
{
constexpr int MAX_SEAMS_PER_FRAME = 16;
// Remove stale entries (nodes that were freed/merged, OR that SPLIT before
// their throttled seam was built). A split node is still ACTIVE but is now
// internal — building a seam on it produces a COARSE-resolution seam that
// spans the parent cell over its fine LOD0 children (the "coarse mesh drawn
// over green" z-fight) and is never freed (the split-frame _on_mesh_free
// already ran). So gate on is_leaf() too.
for (int i = static_cast<int>(seam_pending_.size()) - 1; i >= 0; i--) {
auto* node = seam_pending_[i].node;
if (!node || node->generation != seam_pending_[i].node_generation ||
node->state != OctreeClipmapNode::ACTIVE || !node->is_leaf()) {
seam_pending_.erase(seam_pending_.begin() + i);
}
}
// Cap seam queue at 1024
if (seam_pending_.size() > 1024) {
seam_pending_.erase(seam_pending_.begin(),
seam_pending_.begin() + (seam_pending_.size() - 1024));
}
// Sort by distance to camera (nearest first) for priority processing
if (has_camera && !seam_pending_.empty()) {
std::sort(seam_pending_.begin(), seam_pending_.end(),
[&cam_pos, this](const SeamRequest& a, const SeamRequest& b) {
if (!a.node || !b.node) return a.node != nullptr;
float ax = a.node->position.x * voxel_size_ - cam_pos[0];
float ay = a.node->position.y * voxel_size_ - cam_pos[1];
float az = a.node->position.z * voxel_size_ - cam_pos[2];
float bx = b.node->position.x * voxel_size_ - cam_pos[0];
float by = b.node->position.y * voxel_size_ - cam_pos[1];
float bz = b.node->position.z * voxel_size_ - cam_pos[2];
return (ax*ax+ay*ay+az*az) < (bx*bx+by*by+bz*bz);
});
}
static const int OFFSETS[8][3] = {
{0,0,0}, {0,0,1}, {0,1,0}, {0,1,1},
{1,0,0}, {1,0,1}, {1,1,0}, {1,1,1},
};
// Process nearest chunks first (sorted front-to-back)
int scan = 0;
for (int i = 0; i < static_cast<int>(seam_pending_.size()) && seams_built < MAX_SEAMS_PER_FRAME && scan < 64; i++) {
auto* node = seam_pending_[i].node;
if (!node) continue;
scan++;
// Check if all 8 octant neighbors are ACTIVE (or outside bounds)
bool neighbors_ready = true;
int voxel_span = static_cast<int>(lod_voxel_size(1.0f, node->lod_level) * octree_clipmap_.voxels_per_cell);
for (int d = 0; d < 8; d++) {
int nx = node->position.x + OFFSETS[d][0] * voxel_span;
int ny = node->position.y + OFFSETS[d][1] * voxel_span;
int nz = node->position.z + OFFSETS[d][2] * voxel_span;
auto* neighbor = octree_clipmap_.find_leaf_containing(nx, ny, nz);
if (neighbor && neighbor->state != OctreeClipmapNode::ACTIVE) {
neighbors_ready = false;
break;
}
}
if (neighbors_ready) {
_generate_seam_for_node(node);
seam_pending_.erase(seam_pending_.begin() + i);
i--; // adjust index after erase
seams_built++;
}
}
}
auto t_seam = std::chrono::high_resolution_clock::now();
// 5. Collision: build concave trimesh shapes (delegated to DCCollisionTrimesh).
int collisions_built = 0;
if (collision_mode_ != COLLISION_SDF) {
bool workers_busy = octree_clipmap_.get_stats().pending_count > 0;
Ref<World3D> world = get_world_3d();
collisions_built = collision_trimesh_.process_queue(cam_pos, voxel_size_, workers_busy, world, debug_logging_);
}
auto t_collision = std::chrono::high_resolution_clock::now();
// 6. Progressive deallocation — adaptive drain proportional to queue size
int freed = 0;
{
int target_drain = std::max(4, static_cast<int>(dealloc_queue_.size() / 8));
target_drain = std::min(target_drain, 64); // hard cap to avoid stutter
while (freed < target_drain && !dealloc_queue_.empty()) {
auto& entry = dealloc_queue_.front();
RenderingServer* rs = RenderingServer::get_singleton();
if (rs) {
if (entry.rs_instance.is_valid()) {
rs->free_rid(entry.rs_instance);
}
if (entry.seam_mesh_instance.is_valid()) {
rs->free_rid(entry.seam_mesh_instance);
}
}
PhysicsServer3D* ps = PhysicsServer3D::get_singleton();
if (ps) {
if (entry.collision_body.is_valid()) {
ps->free_rid(entry.collision_body);
}
if (entry.collision_shape.is_valid()) ps->free_rid(entry.collision_shape);
}
entry.mesh.unref();
dealloc_queue_.pop_front();
freed++;
}
}
auto t_dealloc = std::chrono::high_resolution_clock::now();
// RT/CLAS LOD update removed — re-add after CPU MVP
// Debug visualizations
if (debug_seams_) {
_update_debug_seams();
}
if (debug_clipmap_) {
_update_debug_clipmap();
}
auto t_end = std::chrono::high_resolution_clock::now();
// Log frame timing if total _process exceeds 4ms
double total_frame = frame_ms(t_frame, t_end);
if (debug_logging_ && total_frame > 4.0) {
fprintf(stderr, "FRAME: traverse=%.1f dispatch=%.1f(%d,pm=%d) upload=%.1f(%d) seam=%.1f(%d,q=%d) collision=%.1f(%d,q=%d) dealloc=%.1f(%d) TOTAL=%.1fms\n",
frame_ms(t_frame, t_traverse),
frame_ms(t_traverse, t_dispatch), static_cast<int>(pending.size()),
premesh_pending_last_frame_,
frame_ms(t_dispatch, t_upload), uploaded,
frame_ms(t_upload, t_seam), seams_built, static_cast<int>(seam_pending_.size()),
frame_ms(t_seam, t_collision), collisions_built, collision_trimesh_.queue_size(),
frame_ms(t_collision, t_dealloc), freed,
total_frame);
}
}
// ---------------------------------------------------------------------------
// BLOCKING_SWAP: dispatch pre-mesh requests for speculative mesh generation
// ---------------------------------------------------------------------------
void DCTerrain::_dispatch_pre_mesh_requests(int regular_dispatched_this_frame) {
// Queue-depth-based throttle: keep total worker queue depth at a small
// target so workers never idle (LOD streams fast), but edit chunks can
// queue immediately when they arrive and only wait the brief drain time
// of TARGET_QUEUE_DEPTH × per-chunk-compute / NUM_WORKERS.
// Static per-frame caps couldn't balance both — too low starved LOD,
// too high let pre-mesh saturate the queue ahead of edits.
constexpr size_t TARGET_QUEUE_DEPTH = 8;
// Always dispatch at least MIN_PREMESH_PER_FRAME of the CLOSEST pre-mesh
// (the head of the distance-ordered list) so heavy regular generation
// (spawn burst / creatures / edits) can't fully starve near-camera LOD
// refinement and leave chunks frozen coarse (the LOD "chunk freeze").
// The throttle still governs the bulk, preserving edit responsiveness.
constexpr int MIN_PREMESH_PER_FRAME = 4;
auto& cache = octree_clipmap_.pre_mesh_cache_ref();
// Collect the pending set WITH each entry's camera distance in the SAME
// pass we already make over the cache.
//
// This used to push bare mortons and then std::sort them with a comparator
// that did TWO unordered_map lookups per comparison — O(N log N) hash
// lookups EVERY FRAME across the whole backlog. Since the loop below is
// distance-ordered and dispatches at most TARGET_QUEUE_DEPTH per frame, and
// pre_mesh_cache_ has no distance eviction, the far tail is never
// dispatched and never dropped: it just gets re-sorted for ever, so the
// cost grows with exploration and never comes back down.
//
// Measured 2026-09-03 on map_dc_diffusion (841-tile set, same map both
// runs): the dispatch phase averaged 6.12 ms while dispatching ZERO regular
// chunks, climbing 3.61 -> 6.86 -> 9.05 ms over the last 60 s of the run,
// against 1.46 ms before. 11.0 hitches/s (>4 ms) vs 2.9.
std::vector<std::pair<float, uint64_t>> pending; // (distance^2, morton)
pending.reserve(cache.size());
for (auto& [morton, entry] : cache) {
if (entry.state != OctreeClipmapNode::MESH_PENDING) continue;
float dx = entry.position.x * voxel_size_ - prev_cam_pos_[0];
float dy = entry.position.y * voxel_size_ - prev_cam_pos_[1];
float dz = entry.position.z * voxel_size_ - prev_cam_pos_[2];
pending.emplace_back(dx * dx + dy * dy + dz * dz, morton);
}
// Instrumentation only — surfaced in the FRAME line as pm=<n>. Computed
// BEFORE the no-workers return so the backlog is visible during the GPU
// bring-up window, when the pool is deliberately not spawned yet.
premesh_pending_last_frame_ = static_cast<int>(pending.size());
if (!_has_workers()) return;
// Only the HEAD of the distance-ordered list can ever be dispatched: every
// iteration below either dispatches or breaks, and the break fires once the
// queue is at TARGET_QUEUE_DEPTH and MIN_PREMESH_PER_FRAME have gone out —
// so at most TARGET_QUEUE_DEPTH iterations run. partial_sort produces that
// head in EXACTLY the order a full sort would, so which chunks get
// dispatched, and in what order, is unchanged. Only the cost of finding
// them drops, from O(N log N) to O(N log K).
const size_t k = std::min(pending.size(),
TARGET_QUEUE_DEPTH + static_cast<size_t>(MIN_PREMESH_PER_FRAME));
std::partial_sort(pending.begin(), pending.begin() + k, pending.end(),
[](const std::pair<float, uint64_t>& a, const std::pair<float, uint64_t>& b) {
return a.first < b.first;
});
size_t total_queue_depth = 0;
if (using_editor_worker_ && s_editor_worker_) {
total_queue_depth = s_editor_worker_->request_queue_size();
} else {
for (auto& w : workers_) total_queue_depth += w->request_queue_size();
}
(void)regular_dispatched_this_frame; // queue-depth approach makes this redundant
int premesh_dispatched = 0;
for (const auto& pm : pending) {
if (total_queue_depth >= TARGET_QUEUE_DEPTH &&
premesh_dispatched >= MIN_PREMESH_PER_FRAME) break;
const uint64_t morton = pm.second;
auto& entry = cache[morton];
CellRequest req;
req.node = nullptr;
req.pre_mesh_morton = morton;
req.is_pre_mesh = true;
float lod_voxel = lod_voxel_size(voxel_size_, entry.lod_level);
int overlap = 2;
int grid_size = 32;
int nominal = grid_size - 2 * overlap;
float nominal_ws = static_cast<float>(nominal) * lod_voxel;
req.nominal_origin[0] = entry.position.x * voxel_size_;
req.nominal_origin[1] = entry.position.y * voxel_size_;
req.nominal_origin[2] = entry.position.z * voxel_size_;
req.nominal_size = nominal_ws;
req.cell_origin[0] = req.nominal_origin[0] - overlap * lod_voxel;
req.cell_origin[1] = req.nominal_origin[1] - overlap * lod_voxel;
req.cell_origin[2] = req.nominal_origin[2] - overlap * lod_voxel;
req.voxel_size = lod_voxel;
req.grid_dims = grid_size + 1;
req.voxels_per_cell = grid_size;
req.overlap = overlap;
req.node_position = entry.position;
req.lod_level = entry.lod_level;
req.density = &density_;
req.generator = generator_ptr_;
req.gpu_eligible = config_.use_gpu && generator_ptr_ != nullptr && generator_ptr_->has_gpu_shader();
req.use_adaptive_qef = use_adaptive_qef_;
req.use_shortest_diagonal = use_shortest_diagonal_;
req.use_mesh_simplify = use_mesh_simplify_;
// Cull edits to only those overlapping this chunk's AABB — mirror the
// regular dispatch path (see cull at the pending-dispatch loop). This path
// previously passed the FULL edit list to every speculative LOD chunk, so
// each one paid O(all-edits) per voxel in density eval — the dominant
// "CSG edits get laggy as you carve" cost, since pre-mesh runs continuously.
{
float chunk_span = static_cast<float>(grid_size) * lod_voxel;
float cmin[3] = { req.cell_origin[0], req.cell_origin[1], req.cell_origin[2] };
float cmax[3] = { cmin[0] + chunk_span, cmin[1] + chunk_span, cmin[2] + chunk_span };
req.edits = cull_edits_for_chunk(config_.edits, cmin, cmax);
}
req.generation = generation_;
req.node_generation = 0;
req.submit_time_us = std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count();
if (_submit_to_worker(req)) {
entry.state = OctreeClipmapNode::MESH_GENERATING;
entry.dispatch_frame = frame_counter_;
total_queue_depth++;
premesh_dispatched++;
} else {
// Worker queue is full — don't keep trying this frame, leave the
// remaining MESH_PENDING entries for next frame when capacity frees.
break;
}
}
}
// ---------------------------------------------------------------------------
// BLOCKING_SWAP: upload cached meshes for newly-split children / merged parents
// ---------------------------------------------------------------------------
void DCTerrain::_apply_cached_meshes() {
OctreeClipmapNode* root = octree_clipmap_.get_root();
if (!root) return;
auto& cache = octree_clipmap_.pre_mesh_cache_ref();
// Walk the tree looking for ACTIVE leaves that need mesh upload
std::vector<OctreeClipmapNode*> stack;
stack.push_back(root);
while (!stack.empty()) {
OctreeClipmapNode* n = stack.back();
stack.pop_back();
if (!n) continue;
if (n->is_leaf()) {
// Check if this leaf needs a cached mesh uploaded
if (n->state == OctreeClipmapNode::ACTIVE &&
n->mesh_state == OctreeClipmapNode::MESH_UP_TO_DATE &&
!n->mesh_instance.is_valid()) {
auto it = cache.find(n->morton_code);
if (it != cache.end() && it->second.state == OctreeClipmapNode::MESH_UP_TO_DATE) {
// Build CellResult from cached data and upload
CellResult result;
result.node = n;
result.mesh_data = std::move(it->second.data.mesh_data);
result.seam_nodes = std::move(it->second.data.seam_nodes);
result.active_voxel_count = it->second.data.active_voxel_count;
result.triangle_count = it->second.data.triangle_count;
result.geometric_error = it->second.data.geometric_error;
result.success = true;
result.generation = generation_;
result.node_generation = n->generation;
_upload_cell(result);
// Evict consumed cache entry
cache.erase(it);
}
}
} else {
for (int i = 0; i < 8; i++) {
if (n->children[i]) stack.push_back(n->children[i]);
}
}
}
}
void DCTerrain::_update_debug_seams() {
// Create or reuse debug mesh node
if (!debug_mesh_node_) {
debug_mesh_node_ = memnew(MeshInstance3D);
add_child(debug_mesh_node_);
}
Ref<ImmediateMesh> im;
im.instantiate();
// Wireframe material — bright yellow, unshaded, always on top
Ref<StandardMaterial3D> mat;
mat.instantiate();
mat->set_shading_mode(BaseMaterial3D::SHADING_MODE_UNSHADED);
mat->set_albedo(Color(1.0f, 1.0f, 0.0f, 0.8f));
mat->set_transparency(BaseMaterial3D::TRANSPARENCY_ALPHA);
mat->set_flag(BaseMaterial3D::FLAG_DISABLE_DEPTH_TEST, true);
im->surface_begin(Mesh::PRIMITIVE_LINES, mat);
auto leaves = octree_clipmap_.get_leaves();
for (auto* node : leaves) {
if (node->state != OctreeClipmapNode::ACTIVE) continue;
float cs = cell_world_size(node->lod_level, voxel_size_, 32);
float x0 = node->position.x * voxel_size_;
float y0 = node->position.y * voxel_size_;
float z0 = node->position.z * voxel_size_;
float x1 = x0 + cs;
float y1 = y0 + cs;
float z1 = z0 + cs;
// Draw 12 edges of the cell AABB
// Bottom face
im->surface_add_vertex(Vector3(x0, y0, z0)); im->surface_add_vertex(Vector3(x1, y0, z0));
im->surface_add_vertex(Vector3(x1, y0, z0)); im->surface_add_vertex(Vector3(x1, y0, z1));
im->surface_add_vertex(Vector3(x1, y0, z1)); im->surface_add_vertex(Vector3(x0, y0, z1));
im->surface_add_vertex(Vector3(x0, y0, z1)); im->surface_add_vertex(Vector3(x0, y0, z0));
// Top face
im->surface_add_vertex(Vector3(x0, y1, z0)); im->surface_add_vertex(Vector3(x1, y1, z0));
im->surface_add_vertex(Vector3(x1, y1, z0)); im->surface_add_vertex(Vector3(x1, y1, z1));
im->surface_add_vertex(Vector3(x1, y1, z1)); im->surface_add_vertex(Vector3(x0, y1, z1));
im->surface_add_vertex(Vector3(x0, y1, z1)); im->surface_add_vertex(Vector3(x0, y1, z0));
// Verticals
im->surface_add_vertex(Vector3(x0, y0, z0)); im->surface_add_vertex(Vector3(x0, y1, z0));
im->surface_add_vertex(Vector3(x1, y0, z0)); im->surface_add_vertex(Vector3(x1, y1, z0));
im->surface_add_vertex(Vector3(x1, y0, z1)); im->surface_add_vertex(Vector3(x1, y1, z1));
im->surface_add_vertex(Vector3(x0, y0, z1)); im->surface_add_vertex(Vector3(x0, y1, z1));
}
im->surface_end();
debug_mesh_node_->set_mesh(im);
}
void DCTerrain::_update_debug_clipmap() {
if (!debug_clipmap_node_) {
debug_clipmap_node_ = memnew(MeshInstance3D);
auto mat = Ref<StandardMaterial3D>();
mat.instantiate();
mat->set_shading_mode(BaseMaterial3D::SHADING_MODE_UNSHADED);
mat->set_flag(BaseMaterial3D::FLAG_ALBEDO_FROM_VERTEX_COLOR, true);
mat->set_transparency(BaseMaterial3D::TRANSPARENCY_ALPHA);
mat->set_flag(BaseMaterial3D::FLAG_DISABLE_DEPTH_TEST, true);
debug_clipmap_node_->set_material_override(mat);
add_child(debug_clipmap_node_);
}
Ref<ImmediateMesh> im;
im.instantiate();
// Per-LOD colors for wireframe boxes
static const Color lod_colors[] = {
Color(1,1,1,0.9f), Color(0,1,0,0.8f), Color(0,0.5f,1,0.7f),
Color(1,1,0,0.6f), Color(1,0,1,0.5f), Color(0,1,1,0.5f),
Color(1,0.5f,0,0.4f), Color(0.5f,0,1,0.4f), Color(1,0,0,0.3f),
Color(0.5f,1,0,0.3f), Color(0,0.5f,0.5f,0.3f), Color(1,0.5f,0.5f,0.3f)
};
// Single surface with vertex colors — avoids MAX_MESH_SURFACES limit
im->surface_begin(Mesh::PRIMITIVE_LINES);
auto leaves = octree_clipmap_.get_leaves();
for (auto* node : leaves) {
if (node->state != OctreeClipmapNode::ACTIVE) continue;
if (node->triangle_count == 0) continue; // skip empty air chunks
int lod = node->lod_level;
Color col = lod_colors[lod % 12];
float lod_voxel = lod_voxel_size(voxel_size_, lod);
int nominal = 12;
float nominal_ws = static_cast<float>(nominal) * lod_voxel;
float x0 = node->position.x * voxel_size_;
float y0 = node->position.y * voxel_size_;
float z0 = node->position.z * voxel_size_;
float x1 = x0 + nominal_ws;
float y1 = y0 + nominal_ws;
float z1 = z0 + nominal_ws;
// 12 edges of the cell AABB, all with same vertex color
im->surface_set_color(col);
im->surface_add_vertex(Vector3(x0,y0,z0)); im->surface_add_vertex(Vector3(x1,y0,z0));
im->surface_add_vertex(Vector3(x1,y0,z0)); im->surface_add_vertex(Vector3(x1,y0,z1));
im->surface_add_vertex(Vector3(x1,y0,z1)); im->surface_add_vertex(Vector3(x0,y0,z1));
im->surface_add_vertex(Vector3(x0,y0,z1)); im->surface_add_vertex(Vector3(x0,y0,z0));
im->surface_add_vertex(Vector3(x0,y1,z0)); im->surface_add_vertex(Vector3(x1,y1,z0));
im->surface_add_vertex(Vector3(x1,y1,z0)); im->surface_add_vertex(Vector3(x1,y1,z1));
im->surface_add_vertex(Vector3(x1,y1,z1)); im->surface_add_vertex(Vector3(x0,y1,z1));
im->surface_add_vertex(Vector3(x0,y1,z1)); im->surface_add_vertex(Vector3(x0,y1,z0));
im->surface_add_vertex(Vector3(x0,y0,z0)); im->surface_add_vertex(Vector3(x0,y1,z0));
im->surface_add_vertex(Vector3(x1,y0,z0)); im->surface_add_vertex(Vector3(x1,y1,z0));
im->surface_add_vertex(Vector3(x1,y0,z1)); im->surface_add_vertex(Vector3(x1,y1,z1));
im->surface_add_vertex(Vector3(x0,y0,z1)); im->surface_add_vertex(Vector3(x0,y1,z1));
}
im->surface_end();
debug_clipmap_node_->set_mesh(im);
}
void DCTerrain::_spawn_workers() {
// Auto-scale worker count to available CPU cores, capped at 8.
// Higher counts (tested 14 on a 16-core box) actually hurt
// frame times: workers complete meshes faster than the main
// thread can build their collision trimeshes, leaving a
// backlog that produces frame hitches > 100ms.
// The real ceiling is main-thread downstream (collision + upload),
// not worker compute. Until that's parallelized too, 8 workers
// is the sweet spot.
int cpu_count = OS::get_singleton()->get_processor_count();
int worker_count = std::clamp(cpu_count - 2, 2, 8);
if (debug_logging_) {
UtilityFunctions::print("DCTerrain: spawning ", worker_count,
" workers (", cpu_count, " logical cores detected)");
}
for (int i = 0; i < worker_count; i++) {
auto worker = std::make_unique<DCWorkerCPU>();
worker->set_generation(generation_);
// Always pass GPU thread pointer. submit() checks
// gpu_available_ atomically — workers fall back to CPU
// until GPU init completes, then use GPU automatically.
if (gpu_thread_) {
worker->set_gpu_thread(gpu_thread_.get());
}
worker->start(i);
workers_.push_back(std::move(worker));
}
}
void DCTerrain::_notification(int p_what) {
switch (p_what) {
case NOTIFICATION_ENTER_TREE: {
// Single shared worker is only required when GPU is enabled — multiple
// RenderingDevices in the editor crash. CPU-only workers are safe to run
// per-instance in either mode, and 4 workers vs 1 is a 4x throughput win
// (critical when CLI scene-launch reports is_editor_hint=true).
bool force_shared = Engine::get_singleton()->is_editor_hint() && config_.use_gpu;
fprintf(stderr, "[DCTerrain] ENTER_TREE editor_hint=%d use_gpu=%d force_shared=%d prev_workers_size=%d prev_using_editor_worker=%d s_editor_refcount=%d\n",
(int)Engine::get_singleton()->is_editor_hint(),
(int)config_.use_gpu,
(int)force_shared,
(int)workers_.size(),
(int)using_editor_worker_,
s_editor_refcount_);
fflush(stderr);
if (force_shared) {
using_editor_worker_ = true;
if (s_editor_refcount_ == 0) {
s_editor_worker_ = std::make_unique<DCWorkerCPU>();
s_editor_worker_->set_generation(generation_);
s_editor_worker_->start(0);
}
s_editor_refcount_++;
} else {
// Game mode: 4 dedicated workers per instance
using_editor_worker_ = false;
if (workers_.empty()) {
// Start GPU density fill thread if GPU is enabled
if (!gpu_thread_ && config_.use_gpu) {
gpu_thread_ = std::make_unique<DCGpuThread>();
gpu_thread_->start();
}
// ⛔ DO NOT SPAWN WORKERS INTO A GPU THAT IS STILL COMING UP.
//
// start() only spawns the thread; compute_.initialize() then runs
// create_local_rendering_device() — a whole second VkDevice. With
// 8 workers already saturating 16 cores that measured 30.2 s; with
// the cores free it is 4.17 s. A 7.2x difference, and it is pure
// CPU starvation (2026-09-03).
//
// While the GPU is down submit() returns false and every chunk
// falls back to the CPU MESHER — a different dual-contouring
// implementation from qef_solve.glsl. Measured: 47 solid chunks
// came from dc_contour.cpp and then stayed in the world next to
// GPU-meshed neighbours. Waiting takes that to 0.
//
// ⚠ DEFERRED, NOT BLOCKED. A first cut blocked here and cost
// 4.6 s of frozen map load (0.81 s -> 5.44 s) — the right total,
// the wrong shape. _process() spawns them the frame the GPU is
// ready, so load stays responsive and nothing meshes early.
if (gpu_thread_ && !gpu_thread_->is_available() &&
!getenv("DC_NO_GPU_FIRST")) {
workers_deferred_ = true;
workers_deferred_since_ = std::chrono::steady_clock::now();
} else {
_spawn_workers();
}
} else {
generation_++;
_set_worker_generation(generation_);
}
}
// Re-initialize octree if it was cleared by EXIT_TREE.
if (octree_clipmap_.get_stats().leaf_count == 0) {
_propagate_config();
octree_clipmap_.base_voxel_size = voxel_size_;
octree_clipmap_.max_lod_levels = octree_depth_;
// seamless_lod: force all chunks to split to LOD 0 by using a huge lod_factor.
// This makes the distance threshold always trigger splits regardless of distance.
octree_clipmap_.lod_factor = seamless_lod_ ? 1000.0f : 2.0f;
octree_clipmap_.lod_mode = lod_mode_;
octree_clipmap_.on_node_free = _on_node_free;
octree_clipmap_.on_mesh_free = _on_mesh_free;
octree_clipmap_.on_collision_free = _on_collision_free;
octree_clipmap_.on_node_free_userdata = this;
octree_clipmap_.initialize();
debug_frames_ = 0;
}
} break;
case NOTIFICATION_EXIT_TREE: {
// Drain pending results so they don't reference stale node pointers.
{
CellResult discard;
if (using_editor_worker_) {
if (s_editor_worker_) {
while (s_editor_worker_->poll_result(discard)) {}
}
} else {
for (auto& worker : workers_) {
while (worker->poll_result(discard)) {}
}
}
}
// Release shared editor worker ref — but KEEP worker alive.
// In editor mode, scenes constantly enter/exit tree during tab switching.
// Destroying the worker on refcount 0 causes it to be recreated for every
// scene, losing GPU state. Keep it alive for the entire editor session.
if (using_editor_worker_) {
s_editor_refcount_--;
if (s_editor_refcount_ <= 0) {
// Don't stop the worker — it stays alive and idle, ready for next scene.
s_editor_refcount_ = 0;
}
using_editor_worker_ = false;
}
// Free all node RIDs
auto leaves = octree_clipmap_.get_leaves();
for (auto* node : leaves) {
_free_node_rids(node);
}
// Flush dealloc queue
for (auto& entry : dealloc_queue_) {
RenderingServer* rs = RenderingServer::get_singleton();
if (rs) {
if (entry.rs_instance.is_valid()) rs->free_rid(entry.rs_instance);
if (entry.seam_mesh_instance.is_valid()) rs->free_rid(entry.seam_mesh_instance);
}
PhysicsServer3D* ps = PhysicsServer3D::get_singleton();
if (ps) {
if (entry.collision_body.is_valid()) ps->free_rid(entry.collision_body);
if (entry.collision_shape.is_valid()) ps->free_rid(entry.collision_shape);
}
entry.mesh.unref();
}
dealloc_queue_.clear();
collision_trimesh_.clear();
seam_pending_.clear();
node_meshes_.clear();
seam_meshes_.clear();
octree_clipmap_.clear();
// Reset debug counter so diagnostics print again on re-enter
debug_frames_ = 0;
} break;
case NOTIFICATION_TRANSFORM_CHANGED: {
// Cells are placed in world space, not relative to this node.
} break;
}
}
// ---------------------------------------------------------------------------
// rebuild() — public, callable from GDScript. Clears all cells and triggers
// full regeneration.
// ---------------------------------------------------------------------------
void DCTerrain::rebuild() {
if (!is_inside_tree()) return;
UtilityFunctions::print("DCTerrain::rebuild() gen=", generation_ + 1,
" has_workers=", _has_workers(),
" generator=", generator_resource_.is_valid() ? "resource" : "built-in",
" gpu_shader=", (generator_ptr_ && generator_ptr_->has_gpu_shader()) ? "yes" : "no");
// Bump generation — any in-flight worker results will be discarded
generation_++;
// Tell workers to skip requests from old generation BEFORE modifying density.
_set_worker_generation(generation_);
// Drain all in-flight worker results before freeing nodes
_drain_all_workers();
// Reset stuck pre-mesh entries (stale generation → results discarded)
{
auto& cache = octree_clipmap_.pre_mesh_cache_ref();
for (auto& [morton, entry] : cache) {
if (entry.state == OctreeClipmapNode::MESH_GENERATING) {
entry.state = OctreeClipmapNode::MESH_PENDING;
}
}
}
// Free all node RIDs
auto leaves = octree_clipmap_.get_leaves();
for (auto* node : leaves) {
_free_node_rids(node);
}
// Flush dealloc queue
for (auto& entry : dealloc_queue_) {
RenderingServer* rs = RenderingServer::get_singleton();
if (rs) {
if (entry.rs_instance.is_valid()) rs->free_rid(entry.rs_instance);
if (entry.seam_mesh_instance.is_valid()) rs->free_rid(entry.seam_mesh_instance);
}
PhysicsServer3D* ps = PhysicsServer3D::get_singleton();
if (ps && entry.collision_body.is_valid()) ps->free_rid(entry.collision_body);
entry.mesh.unref();
}
dealloc_queue_.clear();
collision_trimesh_.clear();
seam_pending_.clear();
node_meshes_.clear();
seam_meshes_.clear();
// Reconfigure and reinitialize the octree
_propagate_config();
octree_clipmap_.base_voxel_size = voxel_size_;
octree_clipmap_.max_lod_levels = octree_depth_;
// seamless_lod: force all chunks to split to LOD 0 by using a huge lod_factor.
// This makes the distance threshold always trigger splits regardless of distance.
octree_clipmap_.lod_factor = seamless_lod_ ? 1000.0f : 2.0f;
octree_clipmap_.initialize();
// Reset debug counter and frame counter (burst split budget re-activates)
debug_frames_ = 0;
frame_counter_ = 0;
}
// ---------------------------------------------------------------------------
// warmup_shaders() — pre-compile all LOD material pipelines to avoid stutter.
// Creates a tiny triangle mesh, renders it once with each LOD material, then
// frees the mesh. Must be called after terrain_shader_ is created (after
// rebuild() or _enter_tree()).
// ---------------------------------------------------------------------------
void DCTerrain::warmup_shaders() {
if (terrain_shader_.is_null()) return;
static const Color LOD_COLORS[] = {
Color(0.2f, 0.8f, 0.2f), Color(0.2f, 0.6f, 0.8f),
Color(0.8f, 0.8f, 0.2f), Color(0.8f, 0.4f, 0.1f),
Color(0.8f, 0.2f, 0.2f), Color(0.6f, 0.2f, 0.8f),
Color(0.2f, 0.2f, 0.8f), Color(0.8f, 0.6f, 0.8f),
Color(0.4f, 0.4f, 0.4f), Color(1.0f, 1.0f, 1.0f),
Color(0.5f, 0.3f, 0.1f), Color(0.1f, 0.5f, 0.5f),
};
RenderingServer *rs = RenderingServer::get_singleton();
if (!rs || !is_inside_tree()) return;
// Build a tiny triangle mesh for warmup
Ref<ArrayMesh> warmup_mesh;
warmup_mesh.instantiate();
PackedVector3Array verts;
verts.push_back(Vector3(0, 0, 0));
verts.push_back(Vector3(0.01f, 0, 0));
verts.push_back(Vector3(0, 0.01f, 0));
PackedVector3Array normals;
normals.push_back(Vector3(0, 0, 1));
normals.push_back(Vector3(0, 0, 1));
normals.push_back(Vector3(0, 0, 1));
Array arrays;
arrays.resize(Mesh::ARRAY_MAX);
arrays[Mesh::ARRAY_VERTEX] = verts;
arrays[Mesh::ARRAY_NORMAL] = normals;
warmup_mesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, arrays);
// Create a temporary instance at the camera position
RID scenario = get_world_3d()->get_scenario();
RID instance = rs->instance_create();
rs->instance_set_scenario(instance, scenario);
rs->instance_set_base(instance, warmup_mesh->get_rid());
int depth = octree_depth_ > 0 ? octree_depth_ : 5;
int count = depth < MAX_LOD_MATERIALS ? depth : MAX_LOD_MATERIALS;
for (int lod = 0; lod < count; lod++) {
if (lod_materials_[lod].is_null()) {
lod_materials_[lod].instantiate();
lod_materials_[lod]->set_shader(terrain_shader_);
_apply_texture_params(lod_materials_[lod]);
Color c = LOD_COLORS[lod < (int)(sizeof(LOD_COLORS)/sizeof(LOD_COLORS[0]))
? lod : (int)(sizeof(LOD_COLORS)/sizeof(LOD_COLORS[0])) - 1];
lod_materials_[lod]->set_shader_parameter("use_lod_color", debug_lod_colors_);
lod_materials_[lod]->set_shader_parameter("lod_color", Vector3(c.r, c.g, c.b));
lod_materials_[lod]->set_shader_parameter("fade_t", 1.0f);
lod_materials_[lod]->set_shader_parameter("normal_perturbation", normal_perturbation_);
}
warmup_mesh->surface_set_material(0, lod_materials_[lod]);
// Force the RenderingServer to process this material+mesh combination
rs->instance_set_base(instance, warmup_mesh->get_rid());
}
// Also warmup a fading variant (fade_t < 1.0)
Ref<ShaderMaterial> fade_mat;
fade_mat.instantiate();
fade_mat->set_shader(terrain_shader_);
_apply_texture_params(fade_mat);
fade_mat->set_shader_parameter("fade_t", 0.5f);
fade_mat->set_shader_parameter("use_lod_color", false);
fade_mat->set_shader_parameter("normal_perturbation", normal_perturbation_);
warmup_mesh->surface_set_material(0, fade_mat);
rs->instance_set_base(instance, warmup_mesh->get_rid());
// Clean up — the instance rendered this frame, pipelines are queued for compilation
rs->free_rid(instance);
UtilityFunctions::print("[DCTerrain] Shader warmup: ", count, " LOD materials + 1 fade variant precompiled");
}
// ---------------------------------------------------------------------------
// _propagate_config() — update density config
// ---------------------------------------------------------------------------
void DCTerrain::_propagate_config() {
config_.voxel_size = voxel_size_;
// Generators don't apply edits — workers do with per-chunk culled lists.
// Strip edits from the generator config to avoid O(all_edits) per sample.
DensityConfig gen_config = config_;
gen_config.edits.reset();
density_.configure(gen_config);
// Mirror config to the built-in noise generator
noise_generator_.density.configure(gen_config);
// Resolve active generator pointer:
// - If a Resource is assigned, use its native generator
// - Otherwise fall back to the built-in noise_generator_
if (generator_resource_.is_valid()) {
DCGenerator* native = generator_resource_->get_native_generator();
if (native) {
generator_ptr_ = native;
warned_generator_fallback_ = false;
using_builtin_generator_ = false;
} else {
_fallback_to_builtin("_propagate_config");
}
} else if (!generator_ptr_) {
generator_ptr_ = &noise_generator_;
}
}
// A GENERATOR RESOURCE THAT CANNOT PROVIDE AN EVALUATOR IS A CONFIGURATION
// ERROR, and it used to be a silent one.
//
// The built-in noise generator produces a perfectly plausible landscape, so the
// substitution shows up as "the terrain is wrong somehow" rather than as
// anything pointing at the generator. Say it once per transition -- these paths
// run on every config propagation, and a message per frame is a message nobody
// reads. hyper-games #64 NEXT 4.
void DCTerrain::_fallback_to_builtin(const char* where) {
generator_ptr_ = &noise_generator_;
using_builtin_generator_ = true;
if (warned_generator_fallback_) {
return;
}
warned_generator_fallback_ = true;
UtilityFunctions::push_error(
"DCTerrain (", where, "): the assigned generator resource (",
generator_resource_.is_valid() ? generator_resource_->get_class() : String("<null>"),
") returned no native generator, so the BUILT-IN NOISE generator is in "
"use. The terrain you see is not the one this resource describes. A "
"DCTerrainGenerator subclass must override get_native_generator().");
}
void DCTerrain::_drain_workers() {
// Wait for any workers that are mid-_process_cell() to finish.
// This is critical: workers hold a pointer to density_ and read
// config_.csg_primitives during evaluate(). We must wait for them
// to complete before _propagate_config() replaces the config.
_drain_all_workers();
}
// ---------------------------------------------------------------------------
// _upload_cell() — runs on main thread. Creates ArrayMesh and registers it
// with RenderingServer directly (no scene tree nodes).
// ---------------------------------------------------------------------------
void DCTerrain::_upload_cell(CellResult& result) {
auto* node = result.node;
if (!node) return;
// If this node was split while generation was in-flight,
// it's now an internal node. Discard — don't create mesh for internal nodes.
if (!node->is_leaf()) {
return;
}
// Always store geometric error for content-aware LOD
node->geometric_error = result.geometric_error;
if (result.mesh_data.positions.empty() || result.mesh_data.indices.empty()) {
if (node->is_leaf()) {
node->state = OctreeClipmapNode::ACTIVE;
node->mesh_state = OctreeClipmapNode::MESH_UP_TO_DATE;
node->triangle_count = 0;
node->active_voxel_count = 0;
node->content_known = true;
node->fade_t = 1.0f; // nothing to fade, mark complete
node->fade_target = 1.0f;
}
return;
}
auto ms = [](auto t0, auto t1) {
return std::chrono::duration<double, std::milli>(t1 - t0).count();
};
auto t_start = std::chrono::high_resolution_clock::now();
const DCMeshData& mesh_data = result.mesh_data;
// Build ArrayMesh from contour data
int vert_count = static_cast<int>(mesh_data.positions.size() / 3);
int idx_count = static_cast<int>(mesh_data.indices.size());
// Chunk-local vertices: subtract chunk origin so vertices are small numbers
// (0 to ~grid_size * voxel), then use Transform3D to position in world space.
// This prevents float precision loss at large distances (Cepero's approach).
float lod_voxel = lod_voxel_size(voxel_size_, node->lod_level);
float overlap_ws = 2.0f * lod_voxel; // overlap = 2 voxels (matches 16-grid)
float chunk_origin_x = node->position.x * voxel_size_ - overlap_ws;
float chunk_origin_y = node->position.y * voxel_size_ - overlap_ws;
float chunk_origin_z = node->position.z * voxel_size_ - overlap_ws;
PackedVector3Array vertices;
vertices.resize(vert_count);
Vector3* vptr = vertices.ptrw();
for (int i = 0; i < vert_count; i++) {
vptr[i] = Vector3(
mesh_data.positions[i * 3 + 0] - chunk_origin_x,
mesh_data.positions[i * 3 + 1] - chunk_origin_y,
mesh_data.positions[i * 3 + 2] - chunk_origin_z);
}
PackedVector3Array normals_array;
normals_array.resize(vert_count);
Vector3* nptr = normals_array.ptrw();
for (int i = 0; i < vert_count; i++) {
nptr[i] = Vector3(
mesh_data.normals[i * 3 + 0],
mesh_data.normals[i * 3 + 1],
mesh_data.normals[i * 3 + 2]);
}
PackedInt32Array indices_array;
indices_array.resize(idx_count);
int32_t* iptr = indices_array.ptrw();
for (int i = 0; i < idx_count; i++) {
iptr[i] = mesh_data.indices[i];
}
// Route ② texturing: per-vertex material_id → ARRAY_COLOR. R = material_id/255
// (recover as round(R*255)); A = 1 on textured brush faces, 0 on organic
// terrain (→ shader stays triplanar). Empty on the CPU/organic mesh path.
PackedColorArray colors_array;
bool has_materials = (static_cast<int>(mesh_data.materials.size()) == vert_count);
if (has_materials) {
colors_array.resize(vert_count);
Color* cptr = colors_array.ptrw();
for (int i = 0; i < vert_count; i++) {
float m = mesh_data.materials[i];
cptr[i] = Color(m / 255.0f, 0.0f, 0.0f, m > 0.5f ? 1.0f : 0.0f);
}
}
// Stage-2 per-face texturing: two per-vertex projection candidates → ARRAY_CUSTOM0
// (RG_FLOAT). Each is a small integer (< 2^24), exact as a float; the shader picks
// between them per-fragment by normal and looks the winner up in the projection table.
// 0 = none. Two candidates let a sharp crease's two faces each texture correctly.
PackedFloat32Array proj_array;
bool has_proj = (static_cast<int>(mesh_data.proj.size()) == vert_count) &&
(static_cast<int>(mesh_data.proj2.size()) == vert_count);
if (has_proj) {
proj_array.resize(vert_count * 2);
float* pptr = proj_array.ptrw();
for (int i = 0; i < vert_count; i++) {
pptr[i * 2 + 0] = mesh_data.proj[i];
pptr[i * 2 + 1] = mesh_data.proj2[i];
}
}
// CURVATURE + LOD VOXEL SIZE -> ARRAY_CUSTOM1 (RG_FLOAT).
//
// .x is curvature, from qef_solve's horizontal Laplacian of the density
// grid. A separate channel rather than a third component of CUSTOM0: that
// one is RG_FLOAT and every shader reading it expects two projection
// candidates in .xy.
//
// .y is THIS CHUNK'S VOXEL SIZE, and it is here because a material has no
// other way to know how coarse the geometry under it actually is. A shader
// that wants to substitute a finer normal for an under-detailed mesh was
// otherwise reduced to inferring it from the pixel footprint, which is a
// proxy: it gets the boundary wrong whenever the camera climbs, because
// distance-to-camera and chunk LOD stop agreeing. Comparing voxel size
// against the footprint answers "is the mesh coarser than a pixel" exactly.
//
// WRITTEN UNCONDITIONALLY, even when curvature is unavailable. Gating the
// whole channel on has_curv would leave .y at zero on any mesh without
// curvature, and a consumer dividing by it would silently switch itself
// off rather than fail -- the worst kind of wrong.
PackedFloat32Array curv_array;
bool has_curv = (static_cast<int>(mesh_data.curv.size()) == vert_count);
curv_array.resize(vert_count * 2);
{
float* cvp = curv_array.ptrw();
for (int i = 0; i < vert_count; i++) {
cvp[i * 2 + 0] = has_curv ? mesh_data.curv[i] : 0.0f;
cvp[i * 2 + 1] = lod_voxel;
}
}
Array arrays;
arrays.resize(Mesh::ARRAY_MAX);
arrays[Mesh::ARRAY_VERTEX] = vertices;
arrays[Mesh::ARRAY_NORMAL] = normals_array;
if (has_materials) arrays[Mesh::ARRAY_COLOR] = colors_array;
if (has_proj) arrays[Mesh::ARRAY_CUSTOM0] = proj_array;
arrays[Mesh::ARRAY_CUSTOM1] = curv_array;
arrays[Mesh::ARRAY_INDEX] = indices_array;
auto t_arrays = std::chrono::high_resolution_clock::now();
uint64_t surf_flags = 0;
if (has_proj) surf_flags |= (uint64_t)Mesh::ARRAY_CUSTOM_RG_FLOAT << Mesh::ARRAY_FORMAT_CUSTOM0_SHIFT;
surf_flags |= (uint64_t)Mesh::ARRAY_CUSTOM_RG_FLOAT << Mesh::ARRAY_FORMAT_CUSTOM1_SHIFT;
Ref<ArrayMesh> arr_mesh;
arr_mesh.instantiate();
arr_mesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, arrays, Array(), Dictionary(), surf_flags);
auto t_surface = std::chrono::high_resolution_clock::now();
// Per-LOD material with distinct debug colors
static const Color LOD_COLORS[] = {
Color(0.2f, 0.8f, 0.2f), // LOD0: green (finest)
Color(0.2f, 0.6f, 0.8f), // LOD1: cyan
Color(0.8f, 0.8f, 0.2f), // LOD2: yellow
Color(0.8f, 0.4f, 0.1f), // LOD3: orange
Color(0.8f, 0.2f, 0.2f), // LOD4: red
Color(0.6f, 0.2f, 0.8f), // LOD5: purple
Color(0.2f, 0.2f, 0.8f), // LOD6: blue
Color(0.8f, 0.6f, 0.8f), // LOD7: pink
Color(0.4f, 0.4f, 0.4f), // LOD8: gray
Color(1.0f, 1.0f, 1.0f), // LOD9: white
Color(0.5f, 0.3f, 0.1f), // LOD10: brown
Color(0.1f, 0.5f, 0.5f), // LOD11: teal
};
int lod = node->lod_level;
if (lod < 0) lod = 0;
if (lod >= MAX_LOD_MATERIALS) lod = MAX_LOD_MATERIALS - 1;
// Clear any active fade override — new mesh replaces old fade state
fade_overrides_.erase(node);
// Unified ShaderMaterial for all chunks (settled fade_t=1.0).
if (lod_materials_[lod].is_null()) {
lod_materials_[lod].instantiate();
lod_materials_[lod]->set_shader(terrain_shader_);
_apply_texture_params(lod_materials_[lod]);
Color c = debug_lod_colors_ ? LOD_COLORS[lod < (int)(sizeof(LOD_COLORS)/sizeof(LOD_COLORS[0]))
? lod : (int)(sizeof(LOD_COLORS)/sizeof(LOD_COLORS[0])) - 1] : terrain_color_;
lod_materials_[lod]->set_shader_parameter("use_lod_color", debug_lod_colors_);
lod_materials_[lod]->set_shader_parameter("lod_color", Vector3(c.r, c.g, c.b));
lod_materials_[lod]->set_shader_parameter("fade_t", 1.0f);
lod_materials_[lod]->set_shader_parameter("normal_perturbation", normal_perturbation_);
}
arr_mesh->surface_set_material(0, custom_material_.is_valid() ? custom_material_ : Ref<Material>(lod_materials_[lod]));
// If this node needs fading in, create per-chunk override immediately so
// the very first rendered frame uses the correct fade_t (0.0 = invisible).
// Without this, the fade animation loop hasn't run yet for this node,
// so it would render at fade_t=1.0 (opaque) for one frame — visible pop-in.
// Skip in BLOCKING_SWAP mode — no fading, visibility is binary.
if (lod_mode_ != LOD_BLOCKING_SWAP && node->fade_t < 0.99f) {
Ref<ShaderMaterial> mat;
mat.instantiate();
mat->set_shader(terrain_shader_);
_apply_texture_params(mat);
Color c = debug_lod_colors_ ? LOD_COLORS[lod < (int)(sizeof(LOD_COLORS)/sizeof(LOD_COLORS[0]))
? lod : (int)(sizeof(LOD_COLORS)/sizeof(LOD_COLORS[0])) - 1] : terrain_color_;
mat->set_shader_parameter("use_lod_color", debug_lod_colors_);
mat->set_shader_parameter("lod_color", Vector3(c.r, c.g, c.b));
mat->set_shader_parameter("fade_t", node->fade_t);
mat->set_shader_parameter("normal_perturbation", normal_perturbation_);
arr_mesh->surface_set_material(0, mat);
fade_overrides_[node] = mat;
}
// Keep ArrayMesh ref alive (prevent GC while RS holds the RID)
node_meshes_[node] = arr_mesh;
// --- RenderingServer direct instance (no scene tree overhead) ---
RenderingServer* rs = RenderingServer::get_singleton();
RID rs_mesh = arr_mesh->get_rid();
// Clean up any existing mesh instance (e.g., parent mesh kept for crack fill,
// now being replaced after merge + re-generation)
if (node->mesh_instance.is_valid()) {
rs->free_rid(node->mesh_instance);
node->mesh_instance = RID();
}
// Create a visual instance and bind it to the mesh
node->mesh_instance = rs->instance_create();
rs->instance_set_base(node->mesh_instance, rs_mesh);
// Attach to this node's world scenario (required for rendering)
Ref<World3D> world = get_world_3d();
if (world.is_valid()) {
rs->instance_set_scenario(node->mesh_instance, world->get_scenario());
}
// Position chunk in world space via Transform3D (vertices are chunk-local).
Transform3D chunk_xform;
chunk_xform.origin = Vector3(chunk_origin_x, chunk_origin_y, chunk_origin_z);
rs->instance_set_transform(node->mesh_instance, chunk_xform);
node->mesh_origin[0] = chunk_xform.origin.x;
node->mesh_origin[1] = chunk_xform.origin.y;
node->mesh_origin[2] = chunk_xform.origin.z;
// AABB in local space (relative to chunk origin)
float cs = 32.0f * lod_voxel; // full grid extent (includes overlap for conservative culling)
AABB custom_aabb(Vector3(0, 0, 0), Vector3(cs, cs, cs));
rs->instance_set_custom_aabb(node->mesh_instance, custom_aabb);
rs->instance_set_visible(node->mesh_instance, true);
// Note: fade_t shader is applied in _process() animation loop via
// per-chunk ShaderMaterial on the ArrayMesh surface — NOT here.
auto t_rs = std::chrono::high_resolution_clock::now();
// --- Physics: queue collision for near cells within LOD budget ---
// Free existing collision body if chunk was re-meshed (CSG edit, LOD change).
// Without this, process_queue sees the old body as valid and skips the update.
if (node->collision_body.is_valid()) {
PhysicsServer3D* ps = PhysicsServer3D::get_singleton();
if (ps) {
if (node->collision_shape.is_valid()) ps->free_rid(node->collision_shape);
ps->free_rid(node->collision_body);
}
node->collision_body = RID();
node->collision_shape = RID();
}
if (collision_mode_ != COLLISION_SDF &&
collision_trimesh_.should_enqueue(node->lod_level, !result.mesh_data.indices.empty())) {
int tri_count = static_cast<int>(result.mesh_data.indices.size()) / 3;
PackedVector3Array faces;
faces.resize(tri_count * 3);
Vector3* fptr = faces.ptrw();
const float* pos = result.mesh_data.positions.data();
const int* idx = result.mesh_data.indices.data();
// Make faces chunk-local (same as visual mesh) — body transform positions them
for (int t = 0; t < tri_count; t++) {
int i0 = idx[t*3+0], i1 = idx[t*3+1], i2 = idx[t*3+2];
fptr[t*3+0] = Vector3(pos[i0*3] - chunk_origin_x, pos[i0*3+1] - chunk_origin_y, pos[i0*3+2] - chunk_origin_z);
fptr[t*3+1] = Vector3(pos[i1*3] - chunk_origin_x, pos[i1*3+1] - chunk_origin_y, pos[i1*3+2] - chunk_origin_z);
fptr[t*3+2] = Vector3(pos[i2*3] - chunk_origin_x, pos[i2*3+1] - chunk_origin_y, pos[i2*3+2] - chunk_origin_z);
}
DCCollisionTrimesh::CollisionRequest creq;
creq.node = node;
creq.node_generation = node->generation;
creq.faces = std::move(faces);
collision_trimesh_.enqueue(std::move(creq));
}
auto t_physics = std::chrono::high_resolution_clock::now();
// Set node to ACTIVE and store stats
node->state = OctreeClipmapNode::ACTIVE;
node->mesh_state = OctreeClipmapNode::MESH_UP_TO_DATE;
node->triangle_count = result.triangle_count;
node->active_voxel_count = result.active_voxel_count;
node->content_known = true;
// Children cast shadows immediately — both parent and child cast during
// fade transition. Minor double-shadow is less noticeable than black
// flickering from having no shadow casters during the transition.
// Store seam nodes
if (!result.seam_nodes.empty()) {
// Free any previous seam allocation
if (node->seam_nodes) {
delete[] node->seam_nodes;
}
node->seam_nodes = new SeamNodeInfo[result.seam_nodes.size()];
memcpy(node->seam_nodes, result.seam_nodes.data(),
result.seam_nodes.size() * sizeof(SeamNodeInfo));
node->num_seam_nodes = static_cast<int>(result.seam_nodes.size());
}
// Queue for seam mesh generation (needs neighbors to be ACTIVE too)
seam_pending_.push_back({node, node->generation});
// RT/CLAS collection build removed — re-add after CPU MVP
auto t_end = std::chrono::high_resolution_clock::now();
// --- Timing breakdown ---
double total = ms(t_start, t_end);
if (debug_logging_ && total > 1.0) {
fprintf(stderr, "UPLOAD LOD%d (%d,%d,%d): arrays=%.1f surface=%.1f rs=%.1f physics=%.1f TOTAL=%.1fms verts=%d tris=%d\n",
node->lod_level,
node->position.x, node->position.y, node->position.z,
ms(t_start, t_arrays),
ms(t_arrays, t_surface),
ms(t_surface, t_rs),
ms(t_rs, t_physics),
total, vert_count, idx_count / 3);
}
}
// ---------------------------------------------------------------------------
// _generate_seam_for_node() — builds seam mesh by collecting boundary nodes
// from 8 octant neighbors and running DC contouring on the temporary octree.
// ---------------------------------------------------------------------------
void DCTerrain::_generate_seam_for_node(OctreeClipmapNode* node) {
// is_leaf() gate: never build a seam on a node that has split. An internal node
// is still ACTIVE, so the state check alone lets a coarse seam leak on top of the
// fine children (see the stale-filter comment). Second line of defense.
if (!node || node->state != OctreeClipmapNode::ACTIVE || !node->is_leaf()) return;
// Free any existing seam mesh for this node
RenderingServer* rs = RenderingServer::get_singleton();
if (rs && node->seam_mesh_instance.is_valid()) {
rs->free_rid(node->seam_mesh_instance);
node->seam_mesh_instance = RID();
}
static const int OFFSETS[8][3] = {
{0,0,0}, {0,0,1}, {0,1,0}, {0,1,1},
{1,0,0}, {1,0,1}, {1,1,0}, {1,1,1},
};
int voxel_span = static_cast<int>(lod_voxel_size(1.0f, node->lod_level) * octree_clipmap_.voxels_per_cell);
int host_min[3] = { node->position.x, node->position.y, node->position.z };
std::vector<SeamNeighborInfo> neighbors(8);
for (int d = 0; d < 8; d++) {
int nx = host_min[0] + OFFSETS[d][0] * voxel_span;
int ny = host_min[1] + OFFSETS[d][1] * voxel_span;
int nz = host_min[2] + OFFSETS[d][2] * voxel_span;
auto* neighbor = octree_clipmap_.find_leaf_containing(nx, ny, nz);
if (neighbor && neighbor->state == OctreeClipmapNode::ACTIVE &&
neighbor->seam_nodes && neighbor->num_seam_nodes > 0) {
int neighbor_span = static_cast<int>(lod_voxel_size(1.0f, neighbor->lod_level) * octree_clipmap_.voxels_per_cell);
neighbors[d].seam_nodes = neighbor->seam_nodes;
neighbors[d].num_seam_nodes = neighbor->num_seam_nodes;
neighbors[d].cell_size = neighbor_span;
}
}
DCMeshData seam_mesh;
seam_generate_for_cell(host_min, voxel_span, octree_clipmap_.voxels_per_cell, neighbors, seam_mesh);
if (!seam_mesh.positions.empty() && !seam_mesh.indices.empty()) {
_upload_seam_mesh(node, seam_mesh);
}
}
// ---------------------------------------------------------------------------
// _upload_seam_mesh() — uploads a seam mesh as a separate RS instance.
// ---------------------------------------------------------------------------
void DCTerrain::_upload_seam_mesh(OctreeClipmapNode* node, const DCMeshData& mesh_data) {
int vert_count = static_cast<int>(mesh_data.positions.size() / 3);
int idx_count = static_cast<int>(mesh_data.indices.size());
// Seam vertices: make chunk-local (same as visual mesh)
float seam_lod_voxel = lod_voxel_size(voxel_size_, node->lod_level);
float seam_overlap = 1.0f * seam_lod_voxel; // overlap = 1 voxel (matches 16-grid)
float seam_ox = node->position.x * voxel_size_ - seam_overlap;
float seam_oy = node->position.y * voxel_size_ - seam_overlap;
float seam_oz = node->position.z * voxel_size_ - seam_overlap;
PackedVector3Array vertices;
vertices.resize(vert_count);
Vector3* vptr = vertices.ptrw();
for (int i = 0; i < vert_count; i++) {
vptr[i] = Vector3(
mesh_data.positions[i * 3 + 0] - seam_ox,
mesh_data.positions[i * 3 + 1] - seam_oy,
mesh_data.positions[i * 3 + 2] - seam_oz);
}
PackedVector3Array normals_array;
normals_array.resize(vert_count);
Vector3* nptr = normals_array.ptrw();
for (int i = 0; i < vert_count; i++) {
nptr[i] = Vector3(
mesh_data.normals[i * 3 + 0],
mesh_data.normals[i * 3 + 1],
mesh_data.normals[i * 3 + 2]);
}
PackedInt32Array indices_array;
indices_array.resize(idx_count);
int32_t* iptr = indices_array.ptrw();
for (int i = 0; i < idx_count; i++) {
iptr[i] = mesh_data.indices[i];
}
Array arrays;
arrays.resize(Mesh::ARRAY_MAX);
arrays[Mesh::ARRAY_VERTEX] = vertices;
arrays[Mesh::ARRAY_NORMAL] = normals_array;
arrays[Mesh::ARRAY_INDEX] = indices_array;
Ref<ArrayMesh> arr_mesh;
arr_mesh.instantiate();
arr_mesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, arrays);
// Use LOD0 material for seam meshes (seams bridge LOD boundaries)
if (lod_materials_[0].is_valid()) {
arr_mesh->surface_set_material(0, lod_materials_[0]);
}
RenderingServer* rs = RenderingServer::get_singleton();
node->seam_mesh_instance = rs->instance_create();
rs->instance_set_base(node->seam_mesh_instance, arr_mesh->get_rid());
Ref<World3D> world = get_world_3d();
if (world.is_valid()) {
rs->instance_set_scenario(node->seam_mesh_instance, world->get_scenario());
}
// Position seam at chunk origin
Transform3D seam_xform;
seam_xform.origin = Vector3(seam_ox, seam_oy, seam_oz);
rs->instance_set_transform(node->seam_mesh_instance, seam_xform);
// AABB in local space
float cs = cell_world_size(node->lod_level, voxel_size_, 32);
AABB custom_aabb(Vector3(0, 0, 0), Vector3(cs * 2.0f, cs * 2.0f, cs * 2.0f));
rs->instance_set_custom_aabb(node->seam_mesh_instance, custom_aabb);
rs->instance_set_visible(node->seam_mesh_instance, true);
// Keep mesh ref alive — store in node_meshes_ with a shifted key to avoid collision
// Actually, we need a separate map or just ensure the ref stays alive.
// The seam_mesh_instance RID keeps the mesh alive in RS, but we need the
// Ref<ArrayMesh> to prevent GC. Store it alongside the main mesh.
// Use a simple approach: the seam mesh ref is kept alive via a member vector.
// For simplicity, just keep the ref in the existing map won't work (same key).
// Instead, just let the ArrayMesh prevent its own cleanup since RS holds a ref.
// Actually Godot RS does NOT prevent GC — we must hold the ref.
// Store in node_meshes_ won't work since same key. Use a separate small vector.
seam_meshes_[node] = arr_mesh;
if (debug_logging_) {
fprintf(stderr, "SEAM LOD%d (%d,%d,%d): verts=%d tris=%d\n",
node->lod_level, node->position.x, node->position.y, node->position.z,
vert_count, idx_count / 3);
}
}
// ---------------------------------------------------------------------------
// _free_node_rids() — frees RenderingServer + PhysicsServer RIDs for a node.
// Queues for deferred deallocation if called during traversal, or frees
// immediately during cleanup.
// ---------------------------------------------------------------------------
void DCTerrain::_on_node_free(OctreeClipmapNode* node, void* userdata) {
auto* self = static_cast<DCTerrain*>(userdata);
if (!node) return;
// Track freed pointers to prevent use-after-free in result processing
self->freed_nodes_.insert(node);
// Immediately hide mesh instances to prevent LOD stacking
// (parent mesh stays visible until dealloc queue processes it otherwise)
RenderingServer* rs = RenderingServer::get_singleton();
if (rs) {
if (node->mesh_instance.is_valid()) {
rs->instance_set_visible(node->mesh_instance, false);
}
if (node->seam_mesh_instance.is_valid()) {
rs->instance_set_visible(node->seam_mesh_instance, false);
}
}
// Queue RID cleanup via dealloc queue (deferred free to avoid stutter)
if (node->mesh_instance.is_valid() || node->collision_body.is_valid() || node->seam_mesh_instance.is_valid()) {
DeallocEntry entry;
entry.rs_instance = node->mesh_instance;
entry.collision_body = node->collision_body;
entry.collision_shape = node->collision_shape;
entry.seam_mesh_instance = node->seam_mesh_instance;
if (self->node_meshes_.count(node)) {
entry.mesh = self->node_meshes_[node];
}
self->dealloc_queue_.push_back(std::move(entry));
}
node->mesh_instance = RID();
node->collision_body = RID();
node->collision_shape = RID();
node->seam_mesh_instance = RID();
self->node_meshes_.erase(node);
self->seam_meshes_.erase(node);
self->fade_overrides_.erase(node);
if (node->seam_nodes) {
delete[] node->seam_nodes;
node->seam_nodes = nullptr;
node->num_seam_nodes = 0;
}
}
// ---------------------------------------------------------------------------
// _on_mesh_free() — frees ONLY visual mesh, keeps collision alive.
// Called during stale parent cleanup when children are ACTIVE but don't yet
// have their own collision bodies. Prevents collision gaps during LOD cascading.
// ---------------------------------------------------------------------------
void DCTerrain::_on_mesh_free(OctreeClipmapNode* node, void* userdata) {
auto* self = static_cast<DCTerrain*>(userdata);
if (!node) return;
// Hide and queue mesh for deferred free
RenderingServer* rs = RenderingServer::get_singleton();
if (rs) {
if (node->mesh_instance.is_valid()) {
rs->instance_set_visible(node->mesh_instance, false);
}
if (node->seam_mesh_instance.is_valid()) {
rs->instance_set_visible(node->seam_mesh_instance, false);
}
}
if (node->mesh_instance.is_valid() || node->seam_mesh_instance.is_valid()) {
DeallocEntry entry;
entry.rs_instance = node->mesh_instance;
entry.seam_mesh_instance = node->seam_mesh_instance;
// collision_body and collision_shape left as invalid RID — not freed
if (self->node_meshes_.count(node)) {
entry.mesh = self->node_meshes_[node];
}
self->dealloc_queue_.push_back(std::move(entry));
}
node->mesh_instance = RID();
node->seam_mesh_instance = RID();
node->shadow_disabled_for_stale = false;
self->node_meshes_.erase(node);
self->seam_meshes_.erase(node);
self->fade_overrides_.erase(node);
if (node->seam_nodes) {
delete[] node->seam_nodes;
node->seam_nodes = nullptr;
node->num_seam_nodes = 0;
}
}
// ---------------------------------------------------------------------------
// _on_collision_free() — frees ONLY collision body, mesh already freed.
// Called when children finally have their own collision bodies.
// ---------------------------------------------------------------------------
void DCTerrain::_on_collision_free(OctreeClipmapNode* node, void* userdata) {
auto* self = static_cast<DCTerrain*>(userdata);
if (!node) return;
if (node->collision_body.is_valid()) {
DeallocEntry entry;
entry.collision_body = node->collision_body;
entry.collision_shape = node->collision_shape;
self->dealloc_queue_.push_back(std::move(entry));
}
node->collision_body = RID();
node->collision_shape = RID();
}
void DCTerrain::_free_node_rids(OctreeClipmapNode* node) {
if (!node) return;
RenderingServer* rs = RenderingServer::get_singleton();
if (rs) {
if (node->mesh_instance.is_valid()) {
rs->free_rid(node->mesh_instance);
node->mesh_instance = RID();
}
if (node->seam_mesh_instance.is_valid()) {
rs->free_rid(node->seam_mesh_instance);
node->seam_mesh_instance = RID();
}
}
PhysicsServer3D* ps = PhysicsServer3D::get_singleton();
if (ps) {
if (node->collision_body.is_valid()) {
ps->free_rid(node->collision_body);
node->collision_body = RID();
}
if (node->collision_shape.is_valid()) ps->free_rid(node->collision_shape);
node->collision_shape = RID();
}
// Free seam nodes
if (node->seam_nodes) {
delete[] node->seam_nodes;
node->seam_nodes = nullptr;
node->num_seam_nodes = 0;
}
// Release the ArrayMesh refs for this node
auto it = node_meshes_.find(node);
if (it != node_meshes_.end()) {
it->second.unref();
node_meshes_.erase(it);
}
auto sit = seam_meshes_.find(node);
if (sit != seam_meshes_.end()) {
sit->second.unref();
seam_meshes_.erase(sit);
}
}
// ---------------------------------------------------------------------------
// Property getters/setters
// ---------------------------------------------------------------------------
// Helper: set a density config value and rebuild if changed
#define DC_SET_CONFIG(field, val) do { \
if (config_.field != val) { config_.field = val; rebuild(); } \
} while(0)
void DCTerrain::set_seed(int p_seed) { DC_SET_CONFIG(seed, p_seed); }
int DCTerrain::get_seed() const { return config_.seed; }
void DCTerrain::set_primary_frequency(float p_val) { DC_SET_CONFIG(primary_frequency, p_val); }
float DCTerrain::get_primary_frequency() const { return config_.primary_frequency; }
void DCTerrain::set_primary_strength(float p_val) { DC_SET_CONFIG(primary_strength, p_val); }
float DCTerrain::get_primary_strength() const { return config_.primary_strength; }
void DCTerrain::set_tunnel_frequency(float p_val) { DC_SET_CONFIG(tunnel_frequency, p_val); }
float DCTerrain::get_tunnel_frequency() const { return config_.tunnel_frequency; }
void DCTerrain::set_tunnel_strength(float p_val) { DC_SET_CONFIG(tunnel_strength, p_val); }
float DCTerrain::get_tunnel_strength() const { return config_.tunnel_strength; }
void DCTerrain::set_tunnel_radius(float p_val) { DC_SET_CONFIG(tunnel_radius, p_val); }
float DCTerrain::get_tunnel_radius() const { return config_.tunnel_radius; }
void DCTerrain::set_ridge_frequency(float p_val) { DC_SET_CONFIG(ridge_frequency, p_val); }
float DCTerrain::get_ridge_frequency() const { return config_.ridge_frequency; }
void DCTerrain::set_ridge_strength(float p_val) { DC_SET_CONFIG(ridge_strength, p_val); }
float DCTerrain::get_ridge_strength() const { return config_.ridge_strength; }
void DCTerrain::set_warp_frequency(float p_val) { DC_SET_CONFIG(warp_frequency, p_val); }
float DCTerrain::get_warp_frequency() const { return config_.warp_frequency; }
void DCTerrain::set_warp_strength(float p_val) { DC_SET_CONFIG(warp_strength, p_val); }
float DCTerrain::get_warp_strength() const { return config_.warp_strength; }
void DCTerrain::set_detail_frequency(float p_val) { DC_SET_CONFIG(detail_frequency, p_val); }
float DCTerrain::get_detail_frequency() const { return config_.detail_frequency; }
void DCTerrain::set_detail_strength(float p_val) { DC_SET_CONFIG(detail_strength, p_val); }
float DCTerrain::get_detail_strength() const { return config_.detail_strength; }
void DCTerrain::set_ground_bias(float p_val) { DC_SET_CONFIG(ground_bias, p_val); }
float DCTerrain::get_ground_bias() const { return config_.ground_bias; }
void DCTerrain::set_solid_floor_depth(float p_val) { DC_SET_CONFIG(solid_floor_depth, p_val); }
float DCTerrain::get_solid_floor_depth() const { return config_.solid_floor_depth; }
void DCTerrain::set_sky_height(float p_val) { DC_SET_CONFIG(sky_height, p_val); }
float DCTerrain::get_sky_height() const { return config_.sky_height; }
void DCTerrain::set_flat_center_radius(float p_val) { DC_SET_CONFIG(flat_center_radius, p_val); }
float DCTerrain::get_flat_center_radius() const { return config_.flat_center_radius; }
void DCTerrain::set_voronoi_cell_count(int p_val) { DC_SET_CONFIG(voronoi_cell_count, p_val); }
int DCTerrain::get_voronoi_cell_count() const { return config_.voronoi_cell_count; }
void DCTerrain::set_floor_strength(float p_val) { DC_SET_CONFIG(floor_strength, p_val); }
float DCTerrain::get_floor_strength() const { return config_.floor_strength; }
void DCTerrain::set_floor_height_min(float p_val) { DC_SET_CONFIG(floor_height_min, p_val); }
float DCTerrain::get_floor_height_min() const { return config_.floor_height_min; }
void DCTerrain::set_floor_height_max(float p_val) { DC_SET_CONFIG(floor_height_max, p_val); }
float DCTerrain::get_floor_height_max() const { return config_.floor_height_max; }
void DCTerrain::set_room_height(float p_val) { DC_SET_CONFIG(room_height, p_val); }
float DCTerrain::get_room_height() const { return config_.room_height; }
void DCTerrain::set_terrain_size(float p_val) { DC_SET_CONFIG(terrain_size, p_val); }
float DCTerrain::get_terrain_size() const { return config_.terrain_size; }
void DCTerrain::set_use_noise(bool p_val) { DC_SET_CONFIG(use_noise, p_val); }
bool DCTerrain::get_use_noise() const { return config_.use_noise; }
void DCTerrain::set_force_direct_eval(bool p_val) { DC_SET_CONFIG(force_direct_eval, p_val); }
bool DCTerrain::get_force_direct_eval() const { return config_.force_direct_eval; }
void DCTerrain::set_csg_refinement(int p_val) { DC_SET_CONFIG(csg_refinement, std::max(1, std::min(p_val, 4))); }
int DCTerrain::get_csg_refinement() const { return config_.csg_refinement; }
void DCTerrain::set_use_gpu(bool p_val) { DC_SET_CONFIG(use_gpu, p_val); }
bool DCTerrain::get_use_gpu() const { return config_.use_gpu; }
void DCTerrain::set_max_noise_octaves(int p_val) { DC_SET_CONFIG(max_noise_octaves, p_val); }
int DCTerrain::get_max_noise_octaves() const { return config_.max_noise_octaves; }
void DCTerrain::set_max_ridge_octaves(int p_val) { DC_SET_CONFIG(max_ridge_octaves, p_val); }
int DCTerrain::get_max_ridge_octaves() const { return config_.max_ridge_octaves; }
#undef DC_SET_CONFIG
void DCTerrain::set_terrain_color(const Color &p_color) {
terrain_color_ = p_color;
// terrain_color_ currently unused — per-LOD debug colors override it
}
Color DCTerrain::get_terrain_color() const { return terrain_color_; }
void DCTerrain::set_custom_material(const Ref<Material> &p_material) {
custom_material_ = p_material;
// Apply to all existing mesh surfaces
for (auto &pair : node_meshes_) {
if (pair.second.is_valid()) {
if (custom_material_.is_valid()) {
pair.second->surface_set_material(0, custom_material_);
} else {
int lod = pair.first ? pair.first->lod_level : 0;
if (lod < 0) lod = 0;
if (lod >= MAX_LOD_MATERIALS) lod = MAX_LOD_MATERIALS - 1;
pair.second->surface_set_material(0, lod_materials_[lod]);
}
}
}
}
Ref<Material> DCTerrain::get_custom_material() const { return custom_material_; }
void DCTerrain::set_voxel_size(float p_val) {
if (voxel_size_ != p_val) { voxel_size_ = p_val; rebuild(); }
}
float DCTerrain::get_voxel_size() const { return voxel_size_; }
void DCTerrain::set_octree_depth(int p_val) {
if (octree_depth_ != p_val) { octree_depth_ = p_val; rebuild(); }
}
int DCTerrain::get_octree_depth() const { return octree_depth_; }
void DCTerrain::set_seamless_lod(bool p_val) {
if (seamless_lod_ != p_val) { seamless_lod_ = p_val; rebuild(); }
}
bool DCTerrain::get_seamless_lod() const { return seamless_lod_; }
void DCTerrain::set_collision_mode(int p_mode) { collision_mode_ = static_cast<CollisionMode>(p_mode); }
int DCTerrain::get_collision_mode() const { return static_cast<int>(collision_mode_); }
void DCTerrain::set_collision_lod_max(int p_val) { collision_trimesh_.collision_lod_max = p_val; }
int DCTerrain::get_collision_lod_max() const { return collision_trimesh_.collision_lod_max; }
void DCTerrain::set_collision_distance(float p_val) { collision_trimesh_.collision_distance = p_val; }
float DCTerrain::get_collision_distance() const { return collision_trimesh_.collision_distance; }
void DCTerrain::set_use_adaptive_qef(bool p_val) {
if (use_adaptive_qef_ != p_val) { use_adaptive_qef_ = p_val; rebuild(); }
}
bool DCTerrain::get_use_adaptive_qef() const { return use_adaptive_qef_; }
void DCTerrain::_apply_texture_params(const Ref<ShaderMaterial> &mat) const {
if (mat.is_null()) return;
mat->set_shader_parameter("use_textures", use_textures_);
if (tex_atlas_.is_valid()) mat->set_shader_parameter("tex_atlas", tex_atlas_);
mat->set_shader_parameter("has_projection_table", proj_table_tex_.is_valid());
mat->set_shader_parameter("projection_rows", proj_table_count_);
if (proj_table_tex_.is_valid()) mat->set_shader_parameter("projection_table", proj_table_tex_);
mat->set_shader_parameter("use_pbr", glow_atlas_.is_valid() || norm_atlas_.is_valid());
if (glow_atlas_.is_valid()) mat->set_shader_parameter("glow_atlas", glow_atlas_);
if (norm_atlas_.is_valid()) mat->set_shader_parameter("norm_atlas", norm_atlas_);
}
void DCTerrain::set_texture_atlas(const Ref<Texture2DArray> &p_atlas) {
tex_atlas_ = p_atlas;
for (int i = 0; i < MAX_LOD_MATERIALS; i++) _apply_texture_params(lod_materials_[i]);
for (auto &kv : fade_overrides_) _apply_texture_params(kv.second);
UtilityFunctions::print("[DCTerrain] texture atlas set (", p_atlas.is_valid() ? "valid" : "null", ")");
}
void DCTerrain::set_glow_atlas(const Ref<Texture2DArray> &p_atlas) {
glow_atlas_ = p_atlas;
for (int i = 0; i < MAX_LOD_MATERIALS; i++) _apply_texture_params(lod_materials_[i]);
for (auto &kv : fade_overrides_) _apply_texture_params(kv.second);
}
void DCTerrain::set_norm_atlas(const Ref<Texture2DArray> &p_atlas) {
norm_atlas_ = p_atlas;
for (int i = 0; i < MAX_LOD_MATERIALS; i++) _apply_texture_params(lod_materials_[i]);
for (auto &kv : fade_overrides_) _apply_texture_params(kv.second);
}
// Build the per-face projection table as an RGBA32F data texture: 3 texels per entry
// (row-major, width 3, height = entry count). Row r (= projection index r+1 carried by the
// vertex) holds: (layer,u_off,v_off,0)(u_axis.xyz,0)(v_axis.xyz,0). The shader texelFetch()es
// it by the per-vertex projection index to reconstruct the face's Quake UV. See
// scenes/maps/quake_map_importer.gd (_register_face_projection) for the producing side.
void DCTerrain::set_projection_table(const PackedFloat32Array &p_table) {
int entries = p_table.size() / 16;
if (entries <= 0) {
proj_table_tex_ = Ref<Texture2D>();
proj_table_count_ = 0;
} else {
int byte_count = entries * 4 * 16; // 4 texels/row × 16 bytes/RGBA32F texel (texel3 = geometric normal)
PackedByteArray bytes;
bytes.resize(byte_count);
// The float table is already laid out entry-by-entry (16 floats = 4 texels), which
// is exactly the row-major RGBAF byte order Image expects — copy straight through.
memcpy(bytes.ptrw(), p_table.ptr(), byte_count);
Ref<Image> img = Image::create_from_data(4, entries, false, Image::FORMAT_RGBAF, bytes);
proj_table_tex_ = ImageTexture::create_from_image(img);
proj_table_count_ = entries;
}
for (int i = 0; i < MAX_LOD_MATERIALS; i++) _apply_texture_params(lod_materials_[i]);
for (auto &kv : fade_overrides_) _apply_texture_params(kv.second);
UtilityFunctions::print("[DCTerrain] projection table set (", proj_table_count_, " entries)");
}
void DCTerrain::set_use_textures(bool p_val) {
use_textures_ = p_val;
for (int i = 0; i < MAX_LOD_MATERIALS; i++) _apply_texture_params(lod_materials_[i]);
for (auto &kv : fade_overrides_) _apply_texture_params(kv.second);
UtilityFunctions::print("[DCTerrain] use_textures = ", p_val);
}
bool DCTerrain::get_use_textures() const { return use_textures_; }
// Recompute the AABB enclosing all convex-brush (architecture) edits, for adaptive arch LOD.
void DCTerrain::_update_arch_bounds() {
auto edits = edit_list_.snapshot();
float lo[3] = {1e30f, 1e30f, 1e30f}, hi[3] = {-1e30f, -1e30f, -1e30f};
bool any = false;
for (const auto& e : edits) {
if (e.shape != TerrainEdit::CONVEX) continue; // architecture = convex brushes
float r = e.radius > 0.0f ? e.radius : voxel_size_;
lo[0] = std::min(lo[0], e.x - r); hi[0] = std::max(hi[0], e.x + r);
lo[1] = std::min(lo[1], e.y - r); hi[1] = std::max(hi[1], e.y + r);
lo[2] = std::min(lo[2], e.z - r); hi[2] = std::max(hi[2], e.z + r);
any = true;
}
for (int i = 0; i < 3; i++) {
octree_clipmap_.arch_min[i] = any ? lo[i] : 0.0f;
octree_clipmap_.arch_max[i] = any ? hi[i] : 0.0f;
}
UtilityFunctions::print("[DCTerrain] arch bounds = (", octree_clipmap_.arch_min[0], ",",
octree_clipmap_.arch_min[1], ",", octree_clipmap_.arch_min[2], ") .. (",
octree_clipmap_.arch_max[0], ",", octree_clipmap_.arch_max[1], ",", octree_clipmap_.arch_max[2], ")");
}
void DCTerrain::set_adaptive_arch_lod(bool p_val) {
if (adaptive_arch_lod_ == p_val) return;
adaptive_arch_lod_ = p_val;
octree_clipmap_.arch_lod_active = p_val;
if (p_val) _update_arch_bounds(); // capture the current architecture extent
octree_clipmap_.invalidate_settled(); // re-traverse so the change takes effect
UtilityFunctions::print("[DCTerrain] adaptive_arch_lod = ", p_val);
}
bool DCTerrain::get_adaptive_arch_lod() const { return adaptive_arch_lod_; }
void DCTerrain::set_arch_min_lod(int p_lod) {
int l = p_lod < -3 ? -3 : (p_lod > 0 ? 0 : p_lod); // clamp to [-3, 0]
if (octree_clipmap_.arch_min_lod == l) return;
octree_clipmap_.arch_min_lod = l;
octree_clipmap_.invalidate_settled();
UtilityFunctions::print("[DCTerrain] arch_min_lod = ", l);
}
int DCTerrain::get_arch_min_lod() const { return octree_clipmap_.arch_min_lod; }
void DCTerrain::set_use_shortest_diagonal(bool p_val) {
if (use_shortest_diagonal_ != p_val) { use_shortest_diagonal_ = p_val; rebuild(); }
}
bool DCTerrain::get_use_shortest_diagonal() const { return use_shortest_diagonal_; }
void DCTerrain::set_use_mesh_simplify(bool p_val) {
if (use_mesh_simplify_ != p_val) { use_mesh_simplify_ = p_val; rebuild(); }
}
bool DCTerrain::get_use_mesh_simplify() const { return use_mesh_simplify_; }
void DCTerrain::set_debug_logging(bool p_val) {
debug_logging_ = p_val;
}
bool DCTerrain::get_debug_logging() const { return debug_logging_; }
void DCTerrain::set_debug_seams(bool p_val) {
debug_seams_ = p_val;
if (!p_val && debug_mesh_node_) {
debug_mesh_node_->queue_free();
debug_mesh_node_ = nullptr;
}
}
bool DCTerrain::get_debug_seams() const { return debug_seams_; }
void DCTerrain::set_debug_clipmap(bool p_val) {
debug_clipmap_ = p_val;
if (!p_val && debug_clipmap_node_) {
debug_clipmap_node_->queue_free();
debug_clipmap_node_ = nullptr;
}
}
bool DCTerrain::get_debug_clipmap() const { return debug_clipmap_; }
void DCTerrain::set_debug_edits(bool p_val) {
debug_edits_ = p_val;
if (!p_val && debug_edits_node_) {
debug_edits_node_->queue_free();
debug_edits_node_ = nullptr;
}
if (p_val) _update_debug_edits();
}
bool DCTerrain::get_debug_edits() const { return debug_edits_; }
void DCTerrain::_update_debug_edits() {
if (!debug_edits_ || !is_inside_tree()) return;
if (!debug_edits_node_) {
debug_edits_node_ = memnew(MeshInstance3D);
debug_edits_node_->set_name("_DebugEdits");
debug_edits_node_->set_cast_shadows_setting(GeometryInstance3D::SHADOW_CASTING_SETTING_OFF);
debug_edits_node_->set_as_top_level(true); // Don't inherit parent transform
add_child(debug_edits_node_);
}
Ref<ImmediateMesh> im;
im.instantiate();
auto edits = edit_list_.snapshot();
if (edits.empty()) {
debug_edits_node_->set_mesh(Ref<Mesh>());
return;
}
im->surface_begin(Mesh::PRIMITIVE_LINES);
for (const auto& e : edits) {
if (e.shape == TerrainEdit::BOX) {
float hx = e.half_ext[0], hy = e.half_ext[1], hz = e.half_ext[2];
Vector3 c(e.x, e.y, e.z);
// 12 edges of a box
Vector3 corners[8] = {
c + Vector3(-hx, -hy, -hz), c + Vector3( hx, -hy, -hz),
c + Vector3( hx, hy, -hz), c + Vector3(-hx, hy, -hz),
c + Vector3(-hx, -hy, hz), c + Vector3( hx, -hy, hz),
c + Vector3( hx, hy, hz), c + Vector3(-hx, hy, hz),
};
int edges[24] = {0,1, 1,2, 2,3, 3,0, 4,5, 5,6, 6,7, 7,4, 0,4, 1,5, 2,6, 3,7};
Color col = (e.strength < 0.0f) ? Color(1, 0.3, 0.3) : Color(0.3, 1, 0.3);
for (int i = 0; i < 24; i += 2) {
im->surface_set_color(col);
im->surface_add_vertex(corners[edges[i]]);
im->surface_set_color(col);
im->surface_add_vertex(corners[edges[i+1]]);
}
} else if (e.shape == TerrainEdit::SPHERE) {
// Simple cross for spheres
Vector3 c(e.x, e.y, e.z);
float r = e.radius;
Color col = (e.strength < 0.0f) ? Color(1, 0.5, 0.5) : Color(0.5, 1, 0.5);
im->surface_set_color(col); im->surface_add_vertex(c - Vector3(r,0,0));
im->surface_set_color(col); im->surface_add_vertex(c + Vector3(r,0,0));
im->surface_set_color(col); im->surface_add_vertex(c - Vector3(0,r,0));
im->surface_set_color(col); im->surface_add_vertex(c + Vector3(0,r,0));
im->surface_set_color(col); im->surface_add_vertex(c - Vector3(0,0,r));
im->surface_set_color(col); im->surface_add_vertex(c + Vector3(0,0,r));
}
}
im->surface_end();
Ref<StandardMaterial3D> mat;
mat.instantiate();
mat->set_shading_mode(BaseMaterial3D::SHADING_MODE_UNSHADED);
mat->set_flag(BaseMaterial3D::FLAG_ALBEDO_FROM_VERTEX_COLOR, true);
mat->set_transparency(BaseMaterial3D::TRANSPARENCY_DISABLED);
mat->set_flag(BaseMaterial3D::FLAG_DISABLE_DEPTH_TEST, true);
im->surface_set_material(0, mat);
debug_edits_node_->set_mesh(im);
}
int DCTerrain::get_chunk_count() const {
return octree_clipmap_.get_stats().leaf_count;
}
int DCTerrain::get_active_chunk_count() const {
// Count ACTIVE leaves by traversing. For a lightweight stats call,
// we use the stats from the last traverse which tracks leaf_count.
// A more accurate count would traverse all leaves, but this is called
// per-frame from GDScript HUD so must be cheap.
// For now return leaf_count minus pending_count as approximation.
auto& stats = octree_clipmap_.get_stats();
return stats.leaf_count - stats.pending_count;
}
int DCTerrain::get_pending_count() const {
return octree_clipmap_.get_stats().pending_count;
}
int DCTerrain::get_collision_queue_size() const { return collision_trimesh_.queue_size(); }
int DCTerrain::get_splits_this_frame() const {
return octree_clipmap_.get_stats().splits_this_frame;
}
// ---------------------------------------------------------------------------
// Density sampling
// ---------------------------------------------------------------------------
PackedVector3Array DCTerrain::debug_collision_faces(const Vector3 ¢er, float radius) const {
PackedVector3Array out;
PhysicsServer3D *ps = PhysicsServer3D::get_singleton();
if (!ps) return out;
const float r2 = radius * radius;
std::vector<OctreeClipmapNode *> leaves =
const_cast<OctreeClipmap &>(octree_clipmap_).get_leaves();
for (OctreeClipmapNode *n : leaves) {
if (!n || !n->collision_shape.is_valid() || !n->collision_body.is_valid()) continue;
Transform3D xf = ps->body_get_state(n->collision_body,
PhysicsServer3D::BODY_STATE_TRANSFORM);
// CULL BY THE CHUNK'S BOX, NOT BY ITS CORNER. The body origin is the
// chunk's minimum corner and a chunk spans 32 voxels -- 32 m at LOD 0,
// 1024 m at LOD 5. Testing the corner against the probe radius rejects
// the very chunk the player is standing in whenever the probe is
// smaller than the chunk, and an empty return reads as "there is no
// collision here" rather than "the cull threw it away".
const float cs = 32.0f * lod_voxel_size(voxel_size_, n->lod_level);
const Vector3 lo = xf.origin;
const Vector3 hi = xf.origin + Vector3(cs, cs, cs);
const Vector3 q(
std::min(std::max(center.x, lo.x), hi.x),
std::min(std::max(center.y, lo.y), hi.y),
std::min(std::max(center.z, lo.z), hi.z));
if (q.distance_squared_to(center) > r2) continue;
Variant data = ps->shape_get_data(n->collision_shape);
if (data.get_type() != Variant::DICTIONARY) continue;
PackedVector3Array faces = ((Dictionary)data).get("faces", PackedVector3Array());
// WHOLE TRIANGLES, ONE DECISION PER TRIANGLE. The previous form walked
// vertices: an in-range vertex was pushed on its own AND its triangle
// was pushed again by the i%3 branch, so the array was no longer a
// triangle list at all. Every inclination or step computed from it read
// a triangle made of three vertices that were never one.
for (int t = 0; t + 2 < faces.size(); t += 3) {
const Vector3 a = xf.xform(faces[t]);
const Vector3 b = xf.xform(faces[t + 1]);
const Vector3 c = xf.xform(faces[t + 2]);
if (a.distance_squared_to(center) <= r2 ||
b.distance_squared_to(center) <= r2 ||
c.distance_squared_to(center) <= r2) {
out.push_back(a); out.push_back(b); out.push_back(c);
}
}
}
return out;
}
// THE CHUNK UNDER A POINT, as the streamer sees it. Answers the question every
// terrain-collision report needs first and no existing API could: what LOD is
// the ground here, how wide is one of its voxels, and does it have a collision
// body at all. A miss is reported as an EMPTY dictionary rather than as zeros,
// because "there is no leaf here" and "LOD 0" are different findings.
// THE VISUAL MESH, as triangles, in world space -- the counterpart to
// debug_collision_faces, so "is the collision where the terrain looks like it
// is" becomes a measurement instead of an argument from source.
//
// The two are supposed to be the same triangles: _upload_cell builds the
// collision faces from result.mesh_data, the same array it builds the ArrayMesh
// from, and positions both with the same origin expression. Supposed to is not
// measured, and this session has twice caught a difference between what the
// source says and what runs.
PackedVector3Array DCTerrain::debug_visual_faces(const Vector3 ¢er, float radius) const {
PackedVector3Array out;
const float r2 = radius * radius;
std::vector<OctreeClipmapNode *> leaves =
const_cast<OctreeClipmap &>(octree_clipmap_).get_leaves();
for (OctreeClipmapNode *n : leaves) {
if (!n || !n->mesh_instance.is_valid()) continue;
auto it = node_meshes_.find(n);
if (it == node_meshes_.end() || it->second.is_null()) continue;
const float cs = 32.0f * lod_voxel_size(voxel_size_, n->lod_level);
const Vector3 lo(n->mesh_origin[0], n->mesh_origin[1], n->mesh_origin[2]);
const Vector3 hi = lo + Vector3(cs, cs, cs);
const Vector3 q(
std::min(std::max(center.x, lo.x), hi.x),
std::min(std::max(center.y, lo.y), hi.y),
std::min(std::max(center.z, lo.z), hi.z));
if (q.distance_squared_to(center) > r2) continue;
Ref<ArrayMesh> mesh = it->second;
for (int si = 0; si < mesh->get_surface_count(); si++) {
Array arrays = mesh->surface_get_arrays(si);
if (arrays.size() <= Mesh::ARRAY_INDEX) continue;
PackedVector3Array verts = arrays[Mesh::ARRAY_VERTEX];
PackedInt32Array idx = arrays[Mesh::ARRAY_INDEX];
if (verts.is_empty() || idx.is_empty()) continue;
for (int t = 0; t + 2 < idx.size(); t += 3) {
const Vector3 a = verts[idx[t]] + lo;
const Vector3 b = verts[idx[t + 1]] + lo;
const Vector3 c = verts[idx[t + 2]] + lo;
if (a.distance_squared_to(center) <= r2 ||
b.distance_squared_to(center) <= r2 ||
c.distance_squared_to(center) <= r2) {
out.push_back(a); out.push_back(b); out.push_back(c);
}
}
}
}
return out;
}
String DCTerrain::build_stamp() const {
return String("dc-terrain built ") + __DATE__ + " " + __TIME__;
}
Dictionary DCTerrain::debug_chunk_info(const Vector3 &position) const {
Dictionary d;
if (voxel_size_ <= 0.0f) return d;
const int vx = (int)Math::floor(position.x / voxel_size_);
const int vy = (int)Math::floor(position.y / voxel_size_);
const int vz = (int)Math::floor(position.z / voxel_size_);
OctreeClipmapNode *n = octree_clipmap_.find_leaf_containing(vx, vy, vz);
if (!n) return d;
const float lv = lod_voxel_size(voxel_size_, n->lod_level);
d["lod"] = n->lod_level;
d["voxel_size"] = lv;
d["chunk_size"] = 32.0f * lv;
d["state"] = (int)n->state;
// MESH_NEED_UPDATE (4) is the answer to "did my carve reach this chunk":
// an edit marks the chunk dirty, and a chunk still UP_TO_DATE after a carve
// never heard about it.
d["mesh_state"] = (int)n->mesh_state;
d["triangles"] = (int)n->triangle_count;
d["has_collision"] = n->collision_body.is_valid();
d["collision_eligible"] = collision_trimesh_.should_enqueue(n->lod_level, n->triangle_count > 0);
d["origin"] = Vector3(n->position.x, n->position.y, n->position.z) * voxel_size_;
d["parent_collision_stale"] = n->parent_collision_stale;
return d;
}
PackedFloat32Array DCTerrain::debug_gpu_density(const Vector3 &origin, float voxel_size, int cells) {
PackedFloat32Array out;
if (!gpu_thread_ || !generator_ptr_) return out;
if (cells < 1) cells = 1;
DensityGrid grid;
float org[3] = { (float)origin.x, (float)origin.y, (float)origin.z };
grid.allocate(cells, voxel_size, org);
const char* glsl = generator_ptr_->has_gpu_shader()
? generator_ptr_->gpu_shader_source() : nullptr;
const float* hb = generator_ptr_->gpu_custom_data();
int hbn = generator_ptr_->gpu_custom_data_count();
uint32_t hbv = generator_ptr_->gpu_custom_data_version();
const void* const* nv = generator_ptr_->gpu_noise_volumes();
int nvd = generator_ptr_->gpu_noise_dim();
uint32_t nvv = generator_ptr_->gpu_noise_version();
if (!gpu_thread_->submit(grid, density_, glsl, nullptr, nullptr,
nullptr, 0.0f, 0u, hb, hbn, hbv, nv, nvd, nvv)) {
return out; // empty: the GPU declined, and that is the finding
}
out.resize((int64_t)grid.data.size());
memcpy(out.ptrw(), grid.data.data(), grid.data.size() * sizeof(float));
return out;
}
Ref<ArrayMesh> DCTerrain::bake_volume_mesh(const String &p_glsl, const Vector3 &p_origin,
float p_voxel_size, int p_cells) {
Ref<ArrayMesh> out;
if (p_glsl.is_empty()) {
UtilityFunctions::push_error(
"DCTerrain.bake_volume_mesh: no GLSL. A ProcVolume carries one only "
"after its graph compiles -- check ProcVolume.get_glsl_source().");
return out;
}
// BRING THE THREAD UP IF IT IS NOT, into the SAME SLOT the terrain uses.
//
// _notification(ENTER_TREE) only creates it on the game-mode path:
// force_shared = is_editor_hint() && use_gpu, and an editor or
// script-launched instance takes the shared-worker branch and creates no
// GPU thread at all. A bake should not require the terrain to be streaming.
//
// Creating it HERE is safe precisely because it is the same member: if the
// terrain later enters game mode it finds gpu_thread_ non-null and reuses
// it. One device, ever. A second VkDevice is the 30.2 s starvation that
// left two meshers in one world (hyper-games #58).
bool made_it_here = false;
if (!gpu_thread_) {
gpu_thread_ = std::make_unique<DCGpuThread>();
gpu_thread_->start();
made_it_here = true;
UtilityFunctions::print(
"DCTerrain.bake_volume_mesh: no GPU thread existed, starting one "
"(this instance was not streaming).");
}
(void)made_it_here;
if (!gpu_thread_->is_available()) {
// NON-BLOCKING ON PURPOSE. Bring-up takes seconds, and blocking a bound
// call for that freezes the frame -- the same "right total, wrong shape"
// mistake the worker deferral records. Return null and let the caller
// ask again.
static uint64_t s_waits = 0;
if ((s_waits++ % 600) == 0) {
// THROTTLED. Polling this every frame is normal -- the caller
// cannot know when the device is up -- but a warning per frame
// buries the log and adds load to the very bring-up it is waiting
// for. Measured in the full map: ready at 6.2 s, and the poll had
// already logged 9601 times by then.
UtilityFunctions::push_warning(
"DCTerrain.bake_volume_mesh: GPU still coming up (poll #",
(int64_t)s_waits, "). Returning null rather than blocking.");
}
return out;
}
if (p_cells < 1) p_cells = 1;
if (p_voxel_size <= 0.0f) p_voxel_size = 1.0f;
// ⛔ THE ENTRY POINT IS evaluate_density(float,float,float), NOT
// proc_density(vec3). DCCompute::set_custom_shader's own comment says
// "Custom GLSL provides evaluate_density() + helpers", and the template
// calls that name with three floats.
//
// Handing it the graph's text unadapted does NOT fail loudly: the shader
// compiles (proc_density is simply an unused function) and the template's
// own default density runs instead. Measured 2026-09-06 -- a unit-sphere
// volume baked to 225 verts and 392 triangles with EVERY VERTEX AT y = 0.
// A flat plane, produced without one error line.
//
// DCTerrainGeneratorProcNodes writes this same adapter at
// dc_terrain_generator_procnodes.cpp:860; a volume needs only the trivial
// form, with no terrain height mapping, because a ProcVolume is already in
// world space.
baked_volume_glsl_ = std::string(p_glsl.utf8().get_data());
baked_volume_glsl_ +=
"\n\nfloat evaluate_density(float x, float y, float z) {\n"
"\treturn proc_density(vec3(x, y, z));\n"
"}\n";
DensityGrid grid;
float org[3] = { (float)p_origin.x, (float)p_origin.y, (float)p_origin.z };
grid.allocate(p_cells, p_voxel_size, org);
DCMeshData md;
const uint32_t quality = (get_use_adaptive_qef() ? 2u : 0u)
| (get_use_shortest_diagonal() ? 1u : 0u);
if (!gpu_thread_->submit(grid, density_, baked_volume_glsl_.c_str(), nullptr, &md,
org, p_voxel_size, quality, nullptr, 0, 0u)) {
UtilityFunctions::push_error(
"DCTerrain.bake_volume_mesh: the GPU declined the submit. If the "
"custom shader failed to compile, DCGpuThread prints that above.");
return out;
}
const int vcount = (int)md.positions.size() / 3;
if (vcount == 0 || md.indices.empty()) {
// An empty result is a real answer -- a density with no zero crossing
// in these bounds -- and it is worth distinguishing from a failure.
UtilityFunctions::push_warning(
"DCTerrain.bake_volume_mesh: the GPU meshed no surface. The density "
"has no zero crossing inside the bounds given, so either the volume "
"is empty there or the bounds miss it.");
return out;
}
PackedVector3Array pos;
pos.resize(vcount);
Vector3 *pw = pos.ptrw();
for (int i = 0; i < vcount; i++) {
pw[i] = Vector3(md.positions[i * 3], md.positions[i * 3 + 1], md.positions[i * 3 + 2]);
}
PackedInt32Array idx;
idx.resize((int)md.indices.size());
int32_t *iw = idx.ptrw();
for (size_t i = 0; i < md.indices.size(); i++) {
iw[i] = md.indices[i];
}
Array arrays;
arrays.resize(Mesh::ARRAY_MAX);
arrays[Mesh::ARRAY_VERTEX] = pos;
arrays[Mesh::ARRAY_INDEX] = idx;
if ((int)md.normals.size() == vcount * 3) {
PackedVector3Array nrm;
nrm.resize(vcount);
Vector3 *nw = nrm.ptrw();
for (int i = 0; i < vcount; i++) {
nw[i] = Vector3(md.normals[i * 3], md.normals[i * 3 + 1], md.normals[i * 3 + 2]);
}
arrays[Mesh::ARRAY_NORMAL] = nrm;
}
out.instantiate();
out->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, arrays);
return out;
}
float DCTerrain::sample_density(const Vector3 &position) const {
n_sample_density_.fetch_add(1, std::memory_order_relaxed);
float d = generator_ptr_->evaluate(position.x, position.y, position.z, 0.0f);
if (config_.edits && !config_.edits->empty()) {
d = DCEditList::apply(*config_.edits, position.x, position.y, position.z, d);
}
return d;
}
// ---------------------------------------------------------------------------
// SDF collision — evaluate density with edits for correct collision.
// Generator no longer applies edits internally (they're per-chunk culled
// for workers), so collision queries apply the full edit list here.
// Single-point queries: O(all_edits) is fine (one call, not 32K).
// ---------------------------------------------------------------------------
float DCTerrain::_eval_with_edits(float x, float y, float z) const {
n_eval_with_edits_.fetch_add(1, std::memory_order_relaxed);
float d = generator_ptr_->evaluate(x, y, z, 0.0f);
if (config_.edits && !config_.edits->empty()) {
d = DCEditList::apply(*config_.edits, x, y, z, d);
}
return d;
}
float DCTerrain::get_distance(const Vector3 &position) const {
return -_eval_with_edits(position.x, position.y, position.z);
}
Vector3 DCTerrain::get_surface_normal(const Vector3 &position) const {
float h = voxel_size_ * 0.01f;
float x = position.x, y = position.y, z = position.z;
float gx = _eval_with_edits(x+h,y,z) - _eval_with_edits(x-h,y,z);
float gy = _eval_with_edits(x,y+h,z) - _eval_with_edits(x,y-h,z);
float gz = _eval_with_edits(x,y,z+h) - _eval_with_edits(x,y,z-h);
float len = std::sqrt(gx*gx + gy*gy + gz*gz);
if (len < 1e-8f) return Vector3(0, 1, 0);
float inv = -1.0f / len;
return Vector3(gx * inv, gy * inv, gz * inv);
}
Vector3 DCTerrain::get_capsule_push(const Vector3 &position, float radius, float height) const {
float half_h = height * 0.5f;
Vector3 points[3] = {
Vector3(position.x, position.y - half_h + radius, position.z),
position,
Vector3(position.x, position.y + half_h - radius, position.z),
};
Vector3 total_push(0, 0, 0);
for (int i = 0; i < 3; i++) {
float dist = -_eval_with_edits(points[i].x, points[i].y, points[i].z);
if (dist < radius) {
Vector3 normal = get_surface_normal(points[i]);
total_push += normal * (radius - dist);
}
}
return total_push;
}
// Terrain editing
// ---------------------------------------------------------------------------
void DCTerrain::edit_terrain(const Vector3 &position, float radius, float strength, int op, bool interactive, int material_id) {
// op: 0 = OP_SMOOTH (legacy metaball blend), 1 = OP_HARD (sharp SDF CSG,
// union/difference by strength sign). Lets a sphere do the same hard CSG
// ops as a box.
TerrainEdit::CsgOp csg_op = (op == 1) ? TerrainEdit::OP_HARD : TerrainEdit::OP_SMOOTH;
edit_list_.add_sphere(position.x, position.y, position.z, radius, strength, csg_op, (uint32_t)material_id);
config_.edits = std::make_shared<const std::vector<TerrainEdit>>(edit_list_.snapshot());
_propagate_config();
// Incremental regeneration: find leaf cells that overlap the edit sphere
// and re-queue them for generation instead of rebuilding the entire octree.
auto leaves = octree_clipmap_.get_leaves();
int requeued = 0;
for (auto* node : leaves) {
if (node->state != OctreeClipmapNode::ACTIVE &&
node->state != OctreeClipmapNode::READY) continue;
// Compute cell AABB center and half-extent (28 nominal voxels for 32-cell grid)
float lv = lod_voxel_size(voxel_size_, node->lod_level);
float cs = 28.0f * lv;
float cx = node->position.x * voxel_size_ + cs * 0.5f;
float cy = node->position.y * voxel_size_ + cs * 0.5f;
float cz = node->position.z * voxel_size_ + cs * 0.5f;
float half = cs * 0.5f;
// Sphere-AABB overlap test
float dx = std::max(0.0f, std::fabs(position.x - cx) - half);
float dy = std::max(0.0f, std::fabs(position.y - cy) - half);
float dz = std::max(0.0f, std::fabs(position.z - cz) - half);
float dist_sq = dx * dx + dy * dy + dz * dz;
if (dist_sq <= radius * radius) {
// Keep old mesh visible — _upload_cell replaces atomically.
// Only free collision (needs rebuilding with new density).
if (node->collision_body.is_valid()) {
PhysicsServer3D::get_singleton()->free_rid(node->collision_shape);
PhysicsServer3D::get_singleton()->free_rid(node->collision_body);
node->collision_body = RID();
node->collision_shape = RID();
}
if (interactive) node->edit_dirty = true; // LIVE edit only — priority lane (1b)
octree_clipmap_.mark_pending(node);
requeued++;
}
}
if (requeued > 0) {
UtilityFunctions::print("[EDIT] requeued ", requeued,
" cells (pos=", position.x, ",", position.y, ",", position.z,
" r=", radius, " s=", strength, ")");
}
}
void DCTerrain::edit_terrain_box(const Vector3 &position, const Vector3 &half_extents, float strength,
bool interactive, int material_id, bool persistent) {
edit_list_.add_box(position.x, position.y, position.z,
half_extents.x, half_extents.y, half_extents.z, strength,
(uint32_t)material_id, persistent);
// No edit focus point — edits are baked into the density function at all
// LOD levels, so the correct shape appears at whatever resolution the camera
// distance naturally provides. Focus points forced sub-LOD splitting (min_lod=-2)
// which created thousands of tiny chunks per edit, overwhelming workers and
// causing subsequent edits to never complete.
config_.edits = std::make_shared<const std::vector<TerrainEdit>>(edit_list_.snapshot());
_propagate_config();
// Incremental regeneration: AABB-AABB overlap test.
// No global generation bump — that strands non-overlapping GENERATING chunks
// (their stale results get discarded but they're never re-queued).
// Instead, overlapping GENERATING chunks get their node generation bumped
// so their stale in-flight result is dropped, then re-queued.
auto leaves = octree_clipmap_.get_leaves();
int requeued = 0;
for (auto* node : leaves) {
if (node->state != OctreeClipmapNode::ACTIVE &&
node->state != OctreeClipmapNode::READY &&
node->state != OctreeClipmapNode::GENERATING) continue;
float lv = lod_voxel_size(voxel_size_, node->lod_level);
float cs = 28.0f * lv;
float cx = node->position.x * voxel_size_ + cs * 0.5f;
float cy = node->position.y * voxel_size_ + cs * 0.5f;
float cz = node->position.z * voxel_size_ + cs * 0.5f;
float half = cs * 0.5f;
// AABB-AABB overlap — generous padding (2 voxels overlap + half chunk)
// to catch boundary chunks that need the edit for correct seaming.
float pad = lv * 4.0f;
if (std::fabs(position.x - cx) <= half + half_extents.x + pad &&
std::fabs(position.y - cy) <= half + half_extents.y + pad &&
std::fabs(position.z - cz) <= half + half_extents.z + pad) {
if (node->collision_body.is_valid()) {
PhysicsServer3D::get_singleton()->free_rid(node->collision_shape);
PhysicsServer3D::get_singleton()->free_rid(node->collision_body);
node->collision_body = RID();
node->collision_shape = RID();
}
// 1c: do NOT bump generation to drop the in-flight result. Under
// rapid carving that livelocks — every new edit strands the just-
// dispatched remesh, so nothing ever uploads ("edits work for the
// first few then stop"). Instead let the in-flight result upload (it
// carries a prefix of the current edits — a one-frame transient, far
// better than showing nothing); edit_dirty (set below) + the
// post-upload re-queue in _process guarantee exactly one follow-up
// remesh with the latest edits. mark_pending() no-ops on a GENERATING
// node, so the in-flight request is left undisturbed.
// Invalidate pre-mesh cache for this node and its children.
// BLOCKING_SWAP serves cached pre-meshes when splitting; if those
// were generated before this edit, the post-split children render
// without the edit baked in. Mark stale so the worker re-meshes
// with the current edit list on the next pre-mesh request.
{
auto& cache = octree_clipmap_.pre_mesh_cache_ref();
auto it = cache.find(node->morton_code);
if (it != cache.end()) {
it->second.state = OctreeClipmapNode::MESH_NEVER_UPDATED;
}
for (int ci = 0; ci < 8; ci++) {
if (node->children[ci]) {
auto cit = cache.find(node->children[ci]->morton_code);
if (cit != cache.end()) {
cit->second.state = OctreeClipmapNode::MESH_NEVER_UPDATED;
}
}
}
}
int prev_state = (int)node->state;
if (interactive) node->edit_dirty = true; // LIVE edit only — priority lane (1b)
octree_clipmap_.mark_pending(node);
requeued++;
if (debug_logging_) {
UtilityFunctions::print("[LIFE] EDIT_HIT chunk=(",
node->position.x, ",", node->position.y, ",", node->position.z,
") lod=", node->lod_level, " prev_state=", prev_state,
" new_state=", (int)node->state, " node_gen=", node->generation,
" edit_pos=(", position.x, ",", position.y, ",", position.z, ") s=", strength);
}
}
}
if (requeued > 0 && debug_logging_) {
UtilityFunctions::print("[EDIT-BOX] requeued ", requeued,
" cells (pos=", position.x, ",", position.y, ",", position.z,
" ext=", half_extents.x, ",", half_extents.y, ",", half_extents.z,
" s=", strength, ")");
}
_update_debug_edits();
}
void DCTerrain::edit_terrain_convex(const PackedFloat32Array &planes, const Vector3 ¢er,
float radius, float strength, bool interactive, int material_id,
const PackedInt32Array &face_proj) {
int nplanes = planes.size() / 4;
if (nplanes < 4) {
UtilityFunctions::printerr("[CONVEX] need >= 4 planes, got ", nplanes);
return;
}
std::vector<ConvexPlane> pl;
pl.reserve(nplanes);
for (int i = 0; i < nplanes; i++) {
ConvexPlane p;
p.nx = planes[i * 4 + 0]; p.ny = planes[i * 4 + 1];
p.nz = planes[i * 4 + 2]; p.d = planes[i * 4 + 3];
pl.push_back(p);
}
// Per-face projection indices (Stage-2 texturing). Parallel to planes; only kept when
// the caller supplies one per plane (importer path) — else empty (bake uses no proj).
std::vector<uint32_t> proj;
if (face_proj.size() == nplanes) {
proj.reserve(nplanes);
for (int i = 0; i < nplanes; i++) proj.push_back((uint32_t)face_proj[i]);
}
edit_list_.add_convex(center.x, center.y, center.z, pl, strength, (uint32_t)material_id, proj);
config_.edits = std::make_shared<const std::vector<TerrainEdit>>(edit_list_.snapshot());
_propagate_config();
// Requeue affected chunks — bounding-sphere overlap (like edit_terrain sphere).
auto leaves = octree_clipmap_.get_leaves();
int requeued = 0;
for (auto* node : leaves) {
if (node->state != OctreeClipmapNode::ACTIVE &&
node->state != OctreeClipmapNode::READY) continue;
float lv = lod_voxel_size(voxel_size_, node->lod_level);
float cs = 28.0f * lv;
float cx = node->position.x * voxel_size_ + cs * 0.5f;
float cy = node->position.y * voxel_size_ + cs * 0.5f;
float cz = node->position.z * voxel_size_ + cs * 0.5f;
float half = cs * 0.5f;
float dx = std::max(0.0f, std::fabs(center.x - cx) - half);
float dy = std::max(0.0f, std::fabs(center.y - cy) - half);
float dz = std::max(0.0f, std::fabs(center.z - cz) - half);
if (dx * dx + dy * dy + dz * dz <= radius * radius) {
if (node->collision_body.is_valid()) {
PhysicsServer3D::get_singleton()->free_rid(node->collision_shape);
PhysicsServer3D::get_singleton()->free_rid(node->collision_body);
node->collision_body = RID();
node->collision_shape = RID();
}
if (interactive) node->edit_dirty = true; // LIVE edit only — priority lane
octree_clipmap_.mark_pending(node);
requeued++;
}
}
if (debug_logging_) {
UtilityFunctions::print("[CONVEX] ", nplanes, " planes at ", center,
" r=", radius, " requeued ", requeued, " cells");
}
}
// Re-queue every chunk a mesh stamp overlaps, on the terms edit_terrain_box
// established. Both stamp_mesh_* entry points had their own copy of this loop and
// both copies were the PRE-hardening version, so a mesh stamp silently failed to
// reach three classes of chunk:
//
// 1. chunks in GENERATING. The old filter took ACTIVE/READY only. A chunk that
// was mid-mesh when the stamp landed had already been dispatched with a
// pre-stamp edit list (cull_edits_for_chunk snapshots config_.edits AT
// DISPATCH), and the stamp does not bump generation_, so its stale result is
// ACCEPTED at the poll site and uploaded. Skipping it here meant nothing ever
// asked it to try again: it kept pre-stamp geometry for the whole session.
// That is terrain standing inside the level's rooms while debug_sdf_at at
// those same points correctly reports air — the field was never the problem.
//
// 2. cached pre-meshes. BLOCKING_SWAP serves children from the pre-mesh cache on
// an LOD split. Entries generated before the stamp stay MESH_UP_TO_DATE and
// get swapped in wholesale later, re-introducing pre-stamp terrain as the
// camera moves — the "it looks right from here, wrong from there" signature.
//
// 3. the follow-up remesh for a NON-interactive stamp. mark_pending() no-ops on
// a GENERATING node by design, so the post-upload re-queue is the only thing
// that can remesh case 1 — and it was gated on edit_dirty, which a
// non-interactive stamp must not set (priority lane). remesh_after_upload
// carries that guarantee without the lane. See OctreeClipmapNode.
//
// The sphere-AABB test and the 28-voxel cell span are unchanged from the copies
// this replaces; only the reach is fixed.
int DCTerrain::_requeue_chunks_for_mesh_stamp(const Vector3 ¢er, float radius, bool interactive) {
auto leaves = octree_clipmap_.get_leaves();
int requeued = 0;
int deferred = 0; // in flight — can only be reached by the post-upload re-queue
for (auto* node : leaves) {
if (node->state != OctreeClipmapNode::ACTIVE &&
node->state != OctreeClipmapNode::READY &&
node->state != OctreeClipmapNode::GENERATING) continue;
float lv = lod_voxel_size(voxel_size_, node->lod_level);
float cs = 28.0f * lv;
float ncx = node->position.x * voxel_size_ + cs * 0.5f;
float ncy = node->position.y * voxel_size_ + cs * 0.5f;
float ncz = node->position.z * voxel_size_ + cs * 0.5f;
float half = cs * 0.5f;
float dx = std::max(0.0f, std::fabs(center.x - ncx) - half);
float dy = std::max(0.0f, std::fabs(center.y - ncy) - half);
float dz = std::max(0.0f, std::fabs(center.z - ncz) - half);
if (dx*dx + dy*dy + dz*dz > radius * radius) continue;
if (node->collision_body.is_valid()) {
PhysicsServer3D::get_singleton()->free_rid(node->collision_shape);
PhysicsServer3D::get_singleton()->free_rid(node->collision_body);
node->collision_body = RID();
node->collision_shape = RID();
}
// (2) Stale pre-meshes for this node and its children — mirrors
// edit_terrain_box exactly.
{
auto& cache = octree_clipmap_.pre_mesh_cache_ref();
auto it = cache.find(node->morton_code);
if (it != cache.end()) {
it->second.state = OctreeClipmapNode::MESH_NEVER_UPDATED;
}
for (int ci = 0; ci < 8; ci++) {
if (node->children[ci]) {
auto cit = cache.find(node->children[ci]->morton_code);
if (cit != cache.end()) {
cit->second.state = OctreeClipmapNode::MESH_NEVER_UPDATED;
}
}
}
}
if (node->state == OctreeClipmapNode::GENERATING) deferred++;
// (3) Always ask for the follow-up; consumed on dispatch, so a chunk that
// mark_pending() takes right now clears it without a second remesh.
node->remesh_after_upload = true;
if (interactive) node->edit_dirty = true; // LIVE stamp only — priority lane (1b)
octree_clipmap_.mark_pending(node);
requeued++;
}
// UNCONDITIONAL, like the shell's own seal proof. Once per stamp, not per
// frame, and it is the one number that says whether the subtract actually
// reached the geometry: `deferred` chunks are exactly the ones the pre-fix
// code dropped on the floor, and `requeued == 0` means the stamp changed the
// field and nothing that renders it.
UtilityFunctions::print("[STAMP] requeued ", requeued, " cells (",
deferred, " in flight -> post-upload remesh)");
return requeued;
}
// THE CULL SPHERE IS ABOUT THE STAMP POSITION, WHICH IS THE MESH'S CENTROID —
// NOT THE CENTRE OF ITS BOUNDS. Those coincide only for a mesh whose vertices are
// symmetric about its box, and the level shell is the opposite of that: 284k
// vertices in the level plus ~2.5k in an arch 95 m away leaves the centroid
// essentially on the level while the AABB reaches all the way out to the arch.
//
// Both callers used half the AABB DIAGONAL, which is the radius of a sphere
// centred IN THE BOX. Off-centre, the far side of the box sticks out of it — and
// dc_edit.h's `if (dist2 > radius*radius) continue` then silently drops the edit
// at those points. MEASURED with debug_sdf_at: a point in an arch's bore at
// dist 94.57 against radius 82.52, mesh_d +0.300 (genuinely inside the shell),
// and a combined density identical to the base. The cut was correct and was
// being thrown away.
//
// It hid behind something unrelated. While that arch's clearance was
// terrain-measured it ran up to 64 m, which stretched the AABB enough that the
// half-diagonal happened to exceed 94.6 and the arch fell inside the sphere.
// Shortening the clearance shrank the sphere and the cut vanished — reported as
// "it worked one version ago", with nothing in the subtract path having changed.
//
// The correct radius is the FURTHEST AABB CORNER FROM THE STAMP POINT. The mesh
// is stored centred on its centroid, so its local bounds are already relative to
// that point and the furthest corner is the per-axis maximum |extent|.
// Was a second copy of the formula living beside DCEditList's. It is libdc's
// mesh_bounding_radius() now — the list and the focus point cannot disagree.
void DCTerrain::stamp_mesh_obj(const String &obj_path, const Vector3 &position, float scale, float strength, bool interactive, int material_id) {
auto mesh_sdf = std::make_shared<DCGeneratorMeshSDF>();
std::string path = obj_path.utf8().get_data();
if (!mesh_sdf->load_obj(path)) {
UtilityFunctions::printerr("[STAMP] Failed to load OBJ: ", obj_path);
return;
}
if (debug_logging_) {
UtilityFunctions::print("[STAMP] Loaded ", mesh_sdf->triangle_count(), " triangles from ", obj_path);
}
float bounding_radius = mesh_bounding_radius(
std::static_pointer_cast<DCGenerator>(mesh_sdf), scale);
if (debug_logging_) {
UtilityFunctions::print("[STAMP] mesh edit radius=", bounding_radius, " at ", position);
}
// persistent=false: stamp_mesh_obj is the F2/dev stamp, a PLAYER edit — it
// belongs to the journal and a map change should drop it. Only map-authored
// landscape (the level shell, via stamp_mesh_arrays) survives clear_edits().
edit_list_.add_mesh(position.x, position.y, position.z, scale, strength,
std::static_pointer_cast<DCGenerator>(mesh_sdf),
(uint32_t)material_id, false);
float nominal_ws2 = 28.0f * lod_voxel_size(voxel_size_, 0);
// LIVE stamp only: force fine sub-LOD near the stamp. A replayed stamp is
// landscape (already baked into the edit list) — a persistent focus point there
// would force min_lod=-2 forever and over-refine, so gate it out.
if (interactive) octree_clipmap_.edit_focus_points.push_back({position.x, position.y, position.z, bounding_radius + nominal_ws2});
config_.edits = std::make_shared<const std::vector<TerrainEdit>>(edit_list_.snapshot());
_propagate_config();
// Requeue affected chunks — WITH THE CULL RADIUS, which is what
// stamp_mesh_arrays already passes. This was a THIRD formula: half the largest
// AABB extent, i.e. a sphere about the BOX CENTRE, which is the same mistake
// #41 fixed in the cull path and missed here. For an asymmetric mesh it
// under-requeues, so chunks the edit really touches are never re-meshed. The
// two stamp entry points now reach identically.
_requeue_chunks_for_mesh_stamp(position, bounding_radius, interactive);
}
void DCTerrain::stamp_mesh_arrays(const PackedVector3Array &vertices, const PackedInt32Array &indices,
const Vector3 &position, float scale, float strength, bool interactive,
int material_id, bool persistent) {
int num_verts = vertices.size();
int num_tris = indices.size() / 3;
if (num_verts < 3 || num_tris < 1) {
UtilityFunctions::printerr("[STAMP] Invalid mesh: ", num_verts, " verts, ", num_tris, " tris");
return;
}
auto mesh_sdf = std::make_shared<DCGeneratorMeshSDF>();
// Center the mesh at origin before building SDF
Vector3 center = Vector3(0, 0, 0);
for (int i = 0; i < num_verts; i++) center += vertices[i];
center /= num_verts;
std::vector<float> pos_data(num_verts * 3);
for (int i = 0; i < num_verts; i++) {
pos_data[i*3+0] = vertices[i].x - center.x;
pos_data[i*3+1] = vertices[i].y - center.y;
pos_data[i*3+2] = vertices[i].z - center.z;
}
std::vector<int> idx_data(indices.size());
for (int i = 0; i < (int)indices.size(); i++) {
idx_data[i] = indices[i];
}
if (!mesh_sdf->load_arrays(pos_data.data(), num_verts, idx_data.data(), num_tris)) {
UtilityFunctions::printerr("[STAMP] Failed to build mesh SDF from arrays");
return;
}
if (debug_logging_) {
UtilityFunctions::print("[STAMP] Built mesh SDF: ", mesh_sdf->triangle_count(), " tris from ", num_verts, " verts");
}
float bounding_radius = mesh_bounding_radius(
std::static_pointer_cast<DCGenerator>(mesh_sdf), scale);
edit_list_.add_mesh(position.x, position.y, position.z, scale, strength,
std::static_pointer_cast<DCGenerator>(mesh_sdf),
(uint32_t)material_id, persistent);
// Register edit focus point for sub-LOD splitting
float nominal_ws = 28.0f * lod_voxel_size(voxel_size_, 0);
// LIVE stamp only (see stamp_mesh_obj) — a replayed stamp is landscape.
if (interactive) octree_clipmap_.edit_focus_points.push_back({position.x, position.y, position.z, bounding_radius + nominal_ws});
config_.edits = std::make_shared<const std::vector<TerrainEdit>>(edit_list_.snapshot());
_propagate_config();
// Requeue affected chunks
_requeue_chunks_for_mesh_stamp(position, bounding_radius, interactive);
}
void DCTerrain::debug_sdf_at(const Vector3 &position) {
auto edits = edit_list_.snapshot();
UtilityFunctions::print("[SDF-DEBUG] World pos: (", position.x, ",", position.y, ",", position.z, ")");
// Evaluate base terrain density
float base_d = density_.evaluate(position.x, position.y, position.z, voxel_size_);
UtilityFunctions::print("[SDF-DEBUG] Base terrain density: ", base_d);
for (int i = 0; i < (int)edits.size(); i++) {
const auto& e = edits[i];
if (e.shape == TerrainEdit::MESH && e.mesh_sdf) {
float mdx = position.x - e.x, mdy = position.y - e.y, mdz = position.z - e.z;
float dist_to_center = std::sqrt(mdx*mdx + mdy*mdy + mdz*mdz);
float lx = mdx / e.scale, ly = mdy / e.scale, lz = mdz / e.scale;
float mesh_d = e.mesh_sdf->evaluate(lx, ly, lz, 0.0f);
float bmin[3], bmax[3];
e.mesh_sdf->get_bounds(bmin, bmax);
bool in_aabb = lx >= bmin[0] && lx <= bmax[0] &&
ly >= bmin[1] && ly <= bmax[1] &&
lz >= bmin[2] && lz <= bmax[2];
UtilityFunctions::print("[SDF-DEBUG] Edit ", i, " MESH: stamp=(",
e.x, ",", e.y, ",", e.z, ") dist=", dist_to_center,
" local=(", lx, ",", ly, ",", lz, ") mesh_d=", mesh_d,
" in_aabb=", in_aabb, " radius=", e.radius, " scale=", e.scale);
} else if (e.shape == TerrainEdit::BOX) {
UtilityFunctions::print("[SDF-DEBUG] Edit ", i, " BOX at (",
e.x, ",", e.y, ",", e.z, ")");
}
}
// Evaluate combined density (terrain + all edits)
float combined = DCEditList::apply(edits, position.x, position.y, position.z, base_d);
UtilityFunctions::print("[SDF-DEBUG] Combined density: ", combined,
" (positive=solid, negative=air)");
}
void DCTerrain::clear_edits() {
// Drops the PLAYER journal only. Map-authored landscape survives — the §5a.2
// level shell is one MESH edit stamped by the map during import, and this is
// called on every map change by the game's terrain-edit manager, AFTER that
// import has already run. Resetting config_.edits unconditionally here is what
// put terrain back inside START.MAP's rooms: the shell was stamped, wiped a
// few hundred ms later, and the rebuild() below regenerated every chunk from a
// field with no subtract in it.
int kept = edit_list_.clear();
if (kept > 0) {
config_.edits = std::make_shared<const std::vector<TerrainEdit>>(edit_list_.snapshot());
} else {
config_.edits.reset();
}
octree_clipmap_.edit_focus_points.clear();
_propagate_config();
UtilityFunctions::print("[DCTerrain] clear_edits() — player journal dropped, ",
kept, " landscape edit(s) kept");
rebuild();
}
int DCTerrain::get_edit_count() const {
return edit_list_.count();
}
// --- Interactive brush editing (test mutability): pick / move / query a brush ---
// pick_brush: return the index of the convex/box/sphere edit containing world point p
// (nearest centre among containers). -1 if none. Point must be in terrain-local space.
int DCTerrain::pick_brush(const Vector3 &p) const {
auto edits = edit_list_.snapshot();
// v4 — rank brushes by distance from the hit to the brush SURFACE (|SDF|), inside OR
// outside. The hit rides the *rendered* DC surface (which carries ~1 voxel of mesh
// offset), so the brush that formed it sits ~0 from its surface. A strict "contains
// the point" test wrongly drops thin walls and leaves only the big subtractive carve
// void (test 2026-07-14: the only CONTAINING brush was the carve box, 13m deep). We
// prefer ADDITIVE brushes (strength > 0): the carve void (strength < 0) and huge
// enclosing brushes are never what you want to grab.
std::vector<std::pair<float, int>> cands; // (surface distance, edit index)
for (int i = 0; i < (int)edits.size(); i++) {
const TerrainEdit &e = edits[i];
float sd; // magnitude of distance from p to the brush surface
if (e.shape == TerrainEdit::CONVEX) {
if (e.planes.empty()) continue;
float mx = -1e30f; // convex SDF = max over outward half-spaces of (n·p - d)
for (const auto &pl : e.planes)
mx = std::max(mx, pl.nx * p.x + pl.ny * p.y + pl.nz * p.z - pl.d);
sd = std::fabs(mx);
} else if (e.shape == TerrainEdit::BOX) {
float qx = std::fabs(p.x - e.x) - e.half_ext[0];
float qy = std::fabs(p.y - e.y) - e.half_ext[1];
float qz = std::fabs(p.z - e.z) - e.half_ext[2];
float ox = std::max(qx, 0.0f), oy = std::max(qy, 0.0f), oz = std::max(qz, 0.0f);
float outside = std::sqrt(ox * ox + oy * oy + oz * oz); // box SDF (exact outside)
float inside = std::min(std::max(qx, std::max(qy, qz)), 0.0f);
sd = std::fabs(outside + inside);
} else if (e.shape == TerrainEdit::SPHERE) {
float dx = p.x - e.x, dy = p.y - e.y, dz = p.z - e.z;
sd = std::fabs(std::sqrt(dx * dx + dy * dy + dz * dz) - e.radius);
} else {
continue; // MESH: no cheap surface metric — skip for picking
}
cands.push_back(std::make_pair(sd, i));
}
std::sort(cands.begin(), cands.end());
// Pick the nearest-surface ADDITIVE brush; fall back to nearest of any kind.
int best = -1;
for (const auto &c : cands) {
if (edits[c.second].strength > 0.0f) { best = c.second; break; }
}
if (best < 0 && !cands.empty()) best = cands[0].second;
// Ground-truth diagnostic: top candidates by surface distance (H press only, not
// per-frame). ADD/SUB shows the additive-vs-carve split; surfdist ~0 = the surface
// you're aiming at. Lets us confirm the pick from data instead of guessing.
const char *shape_name[] = {"SPHERE", "BOX", "MESH", "CONVEX"};
UtilityFunctions::print("[pick_brush] hit=", p, " ", (int)cands.size(),
" brush(es); picked #", best);
for (int i = 0; i < (int)cands.size() && i < 6; i++) {
int idx = cands[i].second;
const TerrainEdit &e = edits[idx];
int sh = (int)e.shape;
const char *sn = (sh >= 0 && sh < 4) ? shape_name[sh] : "?";
UtilityFunctions::print(" cand #", idx, " ", sn,
(e.strength > 0.0f ? " ADD " : " SUB "),
" surfdist=", cands[i].first,
" center=", Vector3(e.x, e.y, e.z),
(idx == best) ? " <== PICKED" : "");
}
return best;
}
Vector3 DCTerrain::get_brush_center(int idx) const {
auto edits = edit_list_.snapshot();
if (idx < 0 || idx >= (int)edits.size()) return Vector3();
return Vector3(edits[idx].x, edits[idx].y, edits[idx].z);
}
// get_brush_radius: bounding radius of edit `idx` (sphere/convex radius; box half-extent
// diagonal). Lets the GDScript marker size itself to the brush for clear feedback.
float DCTerrain::get_brush_radius(int idx) const {
auto edits = edit_list_.snapshot();
if (idx < 0 || idx >= (int)edits.size()) return 0.0f;
const TerrainEdit &e = edits[idx];
if (e.shape == TerrainEdit::BOX)
return std::sqrt(e.half_ext[0] * e.half_ext[0] + e.half_ext[1] * e.half_ext[1] +
e.half_ext[2] * e.half_ext[2]);
return e.radius;
}
// get_brush_hull_triangles: triangulated convex hull of brush `idx` as a flat triangle
// soup (3 verts/tri) in coordinates RELATIVE TO THE BRUSH CENTER. Lets the GDScript brush
// editor build a faithful preview mesh it moves live, committing the real terrain re-mesh
// only on release (H). Empty for shapes with no cheap hull (sphere → GDScript falls back).
PackedVector3Array DCTerrain::get_brush_hull_triangles(int idx) const {
PackedVector3Array out;
auto edits = edit_list_.snapshot();
if (idx < 0 || idx >= (int)edits.size()) return out;
const TerrainEdit &e = edits[idx];
Vector3 c(e.x, e.y, e.z);
if (e.shape == TerrainEdit::BOX) {
float hx = e.half_ext[0], hy = e.half_ext[1], hz = e.half_ext[2];
Vector3 v[8] = {
Vector3(-hx, -hy, -hz), Vector3(hx, -hy, -hz), Vector3(hx, hy, -hz), Vector3(-hx, hy, -hz),
Vector3(-hx, -hy, hz), Vector3(hx, -hy, hz), Vector3(hx, hy, hz), Vector3(-hx, hy, hz)
};
static const int quad[6][4] = {
{0, 1, 2, 3}, {5, 4, 7, 6}, {4, 5, 1, 0}, {3, 2, 6, 7}, {1, 5, 6, 2}, {4, 0, 3, 7}
};
for (int f = 0; f < 6; f++) {
const int *q = quad[f];
out.push_back(v[q[0]]); out.push_back(v[q[1]]); out.push_back(v[q[2]]);
out.push_back(v[q[0]]); out.push_back(v[q[2]]); out.push_back(v[q[3]]);
}
return out;
}
if (e.shape != TerrainEdit::CONVEX || e.planes.empty()) return out;
int n = (int)e.planes.size();
// 1. Hull vertices = plane-triple intersections lying inside every plane (dedup'd).
std::vector<Vector3> verts;
for (int i = 0; i < n; i++)
for (int j = i + 1; j < n; j++)
for (int k = j + 1; k < n; k++) {
Vector3 ni(e.planes[i].nx, e.planes[i].ny, e.planes[i].nz);
Vector3 nj(e.planes[j].nx, e.planes[j].ny, e.planes[j].nz);
Vector3 nk(e.planes[k].nx, e.planes[k].ny, e.planes[k].nz);
float denom = ni.dot(nj.cross(nk));
if (std::fabs(denom) < 1e-6f) continue;
Vector3 p = (nj.cross(nk) * e.planes[i].d + nk.cross(ni) * e.planes[j].d +
ni.cross(nj) * e.planes[k].d) / denom;
bool inside = true;
for (int m = 0; m < n; m++)
if (e.planes[m].nx * p.x + e.planes[m].ny * p.y + e.planes[m].nz * p.z -
e.planes[m].d > 1e-3f) { inside = false; break; }
if (!inside) continue;
bool dup = false;
for (const auto &q : verts) if ((q - p).length_squared() < 1e-6f) { dup = true; break; }
if (!dup) verts.push_back(p);
}
if (verts.size() < 4) return out;
// 2. Per plane: gather its vertices, sort CCW around the normal, fan-triangulate.
for (int pi = 0; pi < n; pi++) {
Vector3 nrm(e.planes[pi].nx, e.planes[pi].ny, e.planes[pi].nz);
float d = e.planes[pi].d;
std::vector<Vector3> face;
for (const auto &v : verts)
if (std::fabs(nrm.dot(v) - d) < 1e-3f) face.push_back(v);
if (face.size() < 3) continue;
Vector3 cen(0, 0, 0);
for (const auto &v : face) cen += v;
cen /= (float)face.size();
Vector3 t = std::fabs(nrm.x) < 0.9f ? Vector3(1, 0, 0) : Vector3(0, 1, 0);
Vector3 b = nrm.cross(t).normalized();
t = b.cross(nrm).normalized();
std::sort(face.begin(), face.end(), [&](const Vector3 &a, const Vector3 &bb) {
return std::atan2(b.dot(a - cen), t.dot(a - cen)) <
std::atan2(b.dot(bb - cen), t.dot(bb - cen));
});
for (int m = 1; m + 1 < (int)face.size(); m++) {
out.push_back(face[0] - c);
out.push_back(face[m] - c);
out.push_back(face[m + 1] - c);
}
}
return out;
}
// move_brush: translate edit `idx` by delta, then re-mesh the affected region. The old
// invalidate_settled() only unsettled the traversal — it never dirtied any chunk, so the
// brush lingered at its old cell (ghost "copies"), the new cell re-meshed late or not at
// all (invisible mid-move), and nothing rode the interactive priority lane (no realtime
// feedback). Fix = targeted requeue of the chunks overlapping the OLD and NEW brush bounds
// on the edit_dirty priority lane, mirroring edit_terrain_convex's proven path: the old
// cell re-meshes (brush removed → no copies) and the new cell re-meshes fast (visible).
void DCTerrain::move_brush(int idx, const Vector3 &delta) {
auto before = edit_list_.snapshot();
if (idx < 0 || idx >= (int)before.size()) return;
const TerrainEdit &e0 = before[idx];
float ox = e0.x, oy = e0.y, oz = e0.z;
float r = e0.radius;
if (e0.shape == TerrainEdit::BOX)
r = std::sqrt(e0.half_ext[0] * e0.half_ext[0] + e0.half_ext[1] * e0.half_ext[1] +
e0.half_ext[2] * e0.half_ext[2]);
if (r <= 0.0f) r = voxel_size_ * 2.0f; // fallback for a degenerate bound
if (!edit_list_.move_edit(idx, delta.x, delta.y, delta.z)) return;
config_.edits = std::make_shared<const std::vector<TerrainEdit>>(edit_list_.snapshot());
_propagate_config();
// Requeue every ACTIVE/READY leaf whose cell overlaps the brush bounding sphere,
// freeing stale collision and marking edit_dirty (priority lane) + pending.
auto requeue_sphere = [&](float cx, float cy, float cz, float rad) {
auto leaves = octree_clipmap_.get_leaves();
for (auto *node : leaves) {
if (node->state != OctreeClipmapNode::ACTIVE &&
node->state != OctreeClipmapNode::READY) continue;
float lv = lod_voxel_size(voxel_size_, node->lod_level);
float cs = 28.0f * lv;
float ncx = node->position.x * voxel_size_ + cs * 0.5f;
float ncy = node->position.y * voxel_size_ + cs * 0.5f;
float ncz = node->position.z * voxel_size_ + cs * 0.5f;
float half = cs * 0.5f;
float dx = std::max(0.0f, std::fabs(cx - ncx) - half);
float dy = std::max(0.0f, std::fabs(cy - ncy) - half);
float dz = std::max(0.0f, std::fabs(cz - ncz) - half);
if (dx * dx + dy * dy + dz * dz <= rad * rad) {
if (node->collision_body.is_valid()) {
PhysicsServer3D::get_singleton()->free_rid(node->collision_shape);
PhysicsServer3D::get_singleton()->free_rid(node->collision_body);
node->collision_body = RID();
node->collision_shape = RID();
}
node->edit_dirty = true;
octree_clipmap_.mark_pending(node);
}
}
};
requeue_sphere(ox, oy, oz, r); // clear the old cell
requeue_sphere(ox + delta.x, oy + delta.y, oz + delta.z, r); // show the new cell
}
// lift_brush: remove convex brush `idx` from the terrain (re-mesh → it disappears) and
// return its full definition, so the editor can show only the ghost preview while moving
// and re-add the real brush at the drop position on commit. Returns an empty Dictionary
// for non-convex brushes (caller keeps the brush in place and previews without hiding).
Dictionary DCTerrain::lift_brush(int idx) {
Dictionary out;
auto edits = edit_list_.snapshot();
if (idx < 0 || idx >= (int)edits.size()) return out;
const TerrainEdit &e = edits[idx];
if (e.shape != TerrainEdit::CONVEX || e.planes.empty()) return out;
PackedFloat32Array planes;
for (const auto &pl : e.planes) {
planes.push_back(pl.nx); planes.push_back(pl.ny);
planes.push_back(pl.nz); planes.push_back(pl.d);
}
out["planes"] = planes;
out["center"] = Vector3(e.x, e.y, e.z);
out["radius"] = e.radius;
out["strength"] = e.strength;
out["material"] = (int)e.material_id;
float cx = e.x, cy = e.y, cz = e.z;
float r = e.radius > 0.0f ? e.radius : voxel_size_ * 2.0f;
if (!edit_list_.remove_edit(idx)) return Dictionary();
config_.edits = std::make_shared<const std::vector<TerrainEdit>>(edit_list_.snapshot());
_propagate_config();
// Re-mesh the region so the lifted brush disappears (same targeted requeue as move).
auto leaves = octree_clipmap_.get_leaves();
for (auto *node : leaves) {
if (node->state != OctreeClipmapNode::ACTIVE &&
node->state != OctreeClipmapNode::READY) continue;
float lv = lod_voxel_size(voxel_size_, node->lod_level);
float cs = 28.0f * lv;
float ncx = node->position.x * voxel_size_ + cs * 0.5f;
float ncy = node->position.y * voxel_size_ + cs * 0.5f;
float ncz = node->position.z * voxel_size_ + cs * 0.5f;
float half = cs * 0.5f;
float dx = std::max(0.0f, std::fabs(cx - ncx) - half);
float dy = std::max(0.0f, std::fabs(cy - ncy) - half);
float dz = std::max(0.0f, std::fabs(cz - ncz) - half);
if (dx * dx + dy * dy + dz * dz <= r * r) {
if (node->collision_body.is_valid()) {
PhysicsServer3D::get_singleton()->free_rid(node->collision_shape);
PhysicsServer3D::get_singleton()->free_rid(node->collision_body);
node->collision_body = RID();
node->collision_shape = RID();
}
node->edit_dirty = true;
octree_clipmap_.mark_pending(node);
}
}
return out;
}
Array DCTerrain::get_edits_as_array() const {
Array result;
auto edits = edit_list_.snapshot();
for (const auto& e : edits) {
Dictionary d;
if (e.shape == TerrainEdit::SPHERE) {
d["type"] = "sphere";
d["pos"] = Vector3(e.x, e.y, e.z);
d["radius"] = e.radius;
d["strength"] = e.strength;
} else if (e.shape == TerrainEdit::BOX) {
d["type"] = "box";
d["pos"] = Vector3(e.x, e.y, e.z);
d["ext"] = Vector3(e.half_ext[0], e.half_ext[1], e.half_ext[2]);
d["strength"] = e.strength;
}
// Mesh edits not serializable (contain shared_ptr to SDF)
if (!d.is_empty()) result.push_back(d);
}
return result;
}
// Drop map-authored landscape, keep the player journal. Lets a map RE-DERIVE its
// landscape (the level shell) at runtime: clear, rebuild the mesh, stamp again.
// Without it a re-stamp is additive, and two overlapping mesh SDFs in one chunk
// evaluate against a single BVH — see DCEditList::clear_landscape.
//
// Deliberately does NOT rebuild(): the caller stamps immediately afterwards, and
// rebuilding between the two would tear down the octree only to tear it down
// again one call later.
void DCTerrain::clear_landscape_edits() {
int dropped = edit_list_.clear_landscape();
config_.edits = std::make_shared<const std::vector<TerrainEdit>>(edit_list_.snapshot());
_propagate_config();
UtilityFunctions::print("[DCTerrain] clear_landscape_edits() — dropped ", dropped,
" landscape edit(s), player journal kept");
}
void DCTerrain::load_edits_from_array(const Array &edits_array) {
// clear(), NOT clear_all(): mesh edits are not serializable (see the
// serializer above), so landscape stamped by the map could never be restored
// from this array. Keeping it is the only way it survives a journal reload.
edit_list_.clear();
for (int i = 0; i < edits_array.size(); i++) {
Dictionary d = edits_array[i];
String type = d.get("type", "");
Vector3 pos = d.get("pos", Vector3());
float strength = d.get("strength", 1.0f);
if (type == "sphere") {
float radius = d.get("radius", 1.0f);
edit_list_.add_sphere(pos.x, pos.y, pos.z, radius, strength);
} else if (type == "box") {
Vector3 ext = d.get("ext", Vector3(1, 1, 1));
edit_list_.add_box(pos.x, pos.y, pos.z, ext.x, ext.y, ext.z, strength);
}
}
config_.edits = std::make_shared<const std::vector<TerrainEdit>>(edit_list_.snapshot());
_propagate_config();
rebuild();
UtilityFunctions::print("[DCTerrain] Loaded ", edits_array.size(), " edits");
}
void DCTerrain::add_csg_primitive(int type, int op, int group,
const Vector3 &position, const Vector3 &size) {
CSGPrimitive prim;
prim.type = static_cast<CSGPrimitive::Type>(type);
prim.op = static_cast<CSGPrimitive::Op>(op);
prim.group = group;
prim.center[0] = position.x;
prim.center[1] = position.y;
prim.center[2] = position.z;
prim.size[0] = size.x;
prim.size[1] = size.y;
prim.size[2] = size.z;
config_.csg_primitives.push_back(prim);
// Keep sorted by group for sequential evaluation
std::sort(config_.csg_primitives.begin(), config_.csg_primitives.end(),
[](const CSGPrimitive& a, const CSGPrimitive& b) { return a.group < b.group; });
}
void DCTerrain::clear_csg_primitives() {
config_.csg_primitives.clear();
}
int DCTerrain::get_csg_primitive_count() const {
return static_cast<int>(config_.csg_primitives.size());
}
// Requeue all chunks that overlap a sphere centered at `center` with `radius`.
// Used for incremental CSG updates — only regenerate affected chunks.
void DCTerrain::_requeue_affected_chunks(const float center[3], float radius) {
auto leaves = octree_clipmap_.get_leaves();
for (auto* node : leaves) {
if (node->state != OctreeClipmapNode::ACTIVE &&
node->state != OctreeClipmapNode::READY) continue;
// Cell AABB in world space (use 14 nominal voxels for cell size)
float lv = lod_voxel_size(voxel_size_, node->lod_level);
float cs = 14.0f * lv;
float cx = node->position.x * voxel_size_ + cs * 0.5f;
float cy = node->position.y * voxel_size_ + cs * 0.5f;
float cz = node->position.z * voxel_size_ + cs * 0.5f;
float half = cs * 0.5f;
// Sphere-AABB overlap test
float dx = std::max(0.0f, std::fabs(center[0] - cx) - half);
float dy = std::max(0.0f, std::fabs(center[1] - cy) - half);
float dz = std::max(0.0f, std::fabs(center[2] - cz) - half);
if (dx * dx + dy * dy + dz * dz <= radius * radius) {
_on_node_free(node, this);
node->edit_dirty = true; // user edit — worker priority lane (1b)
octree_clipmap_.mark_pending(node);
}
}
}
void DCTerrain::set_csg_primitive_position(int index, const Vector3 &position) {
if (index < 0 || index >= static_cast<int>(config_.csg_primitives.size())) return;
_drain_workers();
auto& prim = config_.csg_primitives[index];
// Compute bounding radius of the old and new positions for incremental rebuild.
// Use the primitive's maximum extent as radius.
float extent = std::max({prim.size[0], prim.size[1], prim.size[2]});
// Requeue chunks affected by the OLD position
float old_center[3] = { prim.center[0], prim.center[1], prim.center[2] };
_requeue_affected_chunks(old_center, extent + voxel_size_ * 4.0f);
// Update position
prim.center[0] = position.x;
prim.center[1] = position.y;
prim.center[2] = position.z;
// Requeue chunks affected by the NEW position
float new_center[3] = { position.x, position.y, position.z };
_requeue_affected_chunks(new_center, extent + voxel_size_ * 4.0f);
_propagate_config();
}
void DCTerrain::set_csg_primitive_size(int index, const Vector3 &size) {
if (index < 0 || index >= static_cast<int>(config_.csg_primitives.size())) return;
_drain_workers();
auto& prim = config_.csg_primitives[index];
// Requeue with the larger of old and new extents
float old_extent = std::max({prim.size[0], prim.size[1], prim.size[2]});
float new_extent = std::max({size.x, size.y, size.z});
float max_extent = std::max(old_extent, new_extent);
float center[3] = { prim.center[0], prim.center[1], prim.center[2] };
_requeue_affected_chunks(center, max_extent + voxel_size_ * 4.0f);
prim.size[0] = size.x;
prim.size[1] = size.y;
prim.size[2] = size.z;
_propagate_config();
}
void DCTerrain::remove_csg_primitive(int index) {
if (index < 0 || index >= static_cast<int>(config_.csg_primitives.size())) return;
_drain_workers();
auto& prim = config_.csg_primitives[index];
float extent = std::max({prim.size[0], prim.size[1], prim.size[2]});
float center[3] = { prim.center[0], prim.center[1], prim.center[2] };
config_.csg_primitives.erase(config_.csg_primitives.begin() + index);
_requeue_affected_chunks(center, extent + voxel_size_ * 4.0f);
_propagate_config();
}
// ---------------------------------------------------------------------------
// Generator resource property
// ---------------------------------------------------------------------------
void DCTerrain::set_generator(const Ref<DCTerrainGenerator> &p_generator) {
// Disconnect from old generator's changed signal
if (generator_resource_.is_valid() && generator_resource_->is_connected("changed", callable_mp(this, &DCTerrain::_on_generator_changed))) {
generator_resource_->disconnect("changed", callable_mp(this, &DCTerrain::_on_generator_changed));
}
generator_resource_ = p_generator;
if (generator_resource_.is_valid()) {
DCGenerator* native = generator_resource_->get_native_generator();
if (native) {
generator_ptr_ = native;
warned_generator_fallback_ = false;
using_builtin_generator_ = false;
} else {
_fallback_to_builtin("set_generator");
}
// Connect to generator's changed signal so parameter edits trigger rebuild
generator_resource_->connect("changed", callable_mp(this, &DCTerrain::_on_generator_changed));
} else {
// Null resource — fall back to built-in noise generator
generator_ptr_ = &noise_generator_;
}
if (is_inside_tree()) {
_propagate_config();
rebuild();
}
}
Ref<DCTerrainGenerator> DCTerrain::get_generator() const {
return generator_resource_;
}
void DCTerrain::_on_generator_changed() {
if (is_inside_tree()) {
rebuild();
}
}
void DCTerrain::set_spherical(bool p_val) {
config_.spherical = p_val;
if (is_inside_tree()) { _propagate_config(); }
}
bool DCTerrain::get_spherical() const { return config_.spherical; }
void DCTerrain::set_floating_islands(bool p_val) {
config_.floating_islands = p_val;
if (is_inside_tree()) { _propagate_config(); }
}
bool DCTerrain::get_floating_islands() const { return config_.floating_islands; }
void DCTerrain::set_island_radius(float p_val) {
config_.island_radius = p_val;
if (is_inside_tree()) { _propagate_config(); }
}
float DCTerrain::get_island_radius() const { return config_.island_radius; }
void DCTerrain::set_island_depth_ratio(float p_val) {
config_.island_depth_ratio = p_val;
if (is_inside_tree()) { _propagate_config(); }
}
float DCTerrain::get_island_depth_ratio() const { return config_.island_depth_ratio; }
void DCTerrain::set_island_spacing(float p_val) {
config_.island_spacing = p_val;
if (is_inside_tree()) { _propagate_config(); }
}
float DCTerrain::get_island_spacing() const { return config_.island_spacing; }
void DCTerrain::set_planet_radius(float p_val) {
config_.planet_radius = p_val;
if (is_inside_tree()) { _propagate_config(); }
}
float DCTerrain::get_planet_radius() const { return config_.planet_radius; }
void DCTerrain::set_screen_split_threshold(float p_val) {
octree_clipmap_.screen_split_threshold = p_val;
octree_clipmap_.screen_merge_threshold = p_val * 0.5f; // hysteresis
}
float DCTerrain::get_screen_split_threshold() const {
return octree_clipmap_.screen_split_threshold;
}
void DCTerrain::set_use_screen_space_error(bool p_val) {
octree_clipmap_.use_screen_space_error = p_val;
}
bool DCTerrain::get_use_screen_space_error() const {
return octree_clipmap_.use_screen_space_error;
}
void DCTerrain::set_lod_factor(float p_val) {
lod_factor_ = p_val;
octree_clipmap_.lod_factor = p_val;
}
float DCTerrain::get_lod_factor() const { return lod_factor_; }
void DCTerrain::set_hysteresis(float p_val) {
hysteresis_ = p_val;
octree_clipmap_.hysteresis = p_val;
}
float DCTerrain::get_hysteresis() const { return hysteresis_; }
void DCTerrain::set_fade_speed(float p_val) { fade_speed_ = p_val; }
float DCTerrain::get_fade_speed() const { return fade_speed_; }
void DCTerrain::set_normal_perturbation(float p_val) {
normal_perturbation_ = p_val;
// Update all existing lod materials
for (int i = 0; i < MAX_LOD_MATERIALS; i++) {
if (lod_materials_[i].is_valid()) {
lod_materials_[i]->set_shader_parameter("normal_perturbation", p_val);
}
}
// Update active fade overrides
for (auto& [node, mat] : fade_overrides_) {
if (mat.is_valid()) {
mat->set_shader_parameter("normal_perturbation", p_val);
}
}
}
float DCTerrain::get_normal_perturbation() const { return normal_perturbation_; }
void DCTerrain::set_debug_lod_colors(bool p_val) {
debug_lod_colors_ = p_val;
// Invalidate cached materials so they rebuild with new colors
for (int i = 0; i < MAX_LOD_MATERIALS; i++) {
lod_materials_[i].unref();
}
// LOD_COLORS table (same as _upload_cell)
static const Color LOD_COLORS[] = {
Color(0.2f, 0.8f, 0.2f), Color(0.2f, 0.6f, 0.8f),
Color(0.8f, 0.8f, 0.2f), Color(0.8f, 0.4f, 0.1f),
Color(0.8f, 0.2f, 0.2f), Color(0.6f, 0.2f, 0.8f),
Color(0.2f, 0.2f, 0.8f), Color(0.8f, 0.6f, 0.8f),
Color(0.4f, 0.4f, 0.4f), Color(1.0f, 1.0f, 1.0f),
Color(0.5f, 0.3f, 0.1f), Color(0.1f, 0.5f, 0.5f),
};
// Recolor all existing chunk meshes
for (auto& [node, mesh] : node_meshes_) {
if (!mesh.is_valid() || mesh->get_surface_count() == 0) continue;
int lod = node->lod_level;
if (lod < 0) lod = 0;
if (lod >= MAX_LOD_MATERIALS) lod = MAX_LOD_MATERIALS - 1;
// Lazy-init material (unified ShaderMaterial)
if (lod_materials_[lod].is_null()) {
lod_materials_[lod].instantiate();
lod_materials_[lod]->set_shader(terrain_shader_);
_apply_texture_params(lod_materials_[lod]);
Color c = debug_lod_colors_ ? LOD_COLORS[lod < (int)(sizeof(LOD_COLORS)/sizeof(LOD_COLORS[0]))
? lod : (int)(sizeof(LOD_COLORS)/sizeof(LOD_COLORS[0])) - 1] : terrain_color_;
lod_materials_[lod]->set_shader_parameter("use_lod_color", debug_lod_colors_);
lod_materials_[lod]->set_shader_parameter("lod_color", Vector3(c.r, c.g, c.b));
lod_materials_[lod]->set_shader_parameter("fade_t", 1.0f);
lod_materials_[lod]->set_shader_parameter("normal_perturbation", normal_perturbation_);
}
// Skip chunks with active fade override
if (fade_overrides_.count(node)) continue;
mesh->surface_set_material(0, lod_materials_[lod]);
}
}
bool DCTerrain::get_debug_lod_colors() const { return debug_lod_colors_; }
void DCTerrain::set_debug_wireframe(bool p_val) {
debug_wireframe_ = p_val;
if (!is_inside_tree()) return;
Viewport* vp = get_viewport();
if (!vp) return;
if (p_val) {
vp->set_debug_draw(Viewport::DEBUG_DRAW_WIREFRAME);
} else {
vp->set_debug_draw(Viewport::DEBUG_DRAW_DISABLED);
}
}
bool DCTerrain::get_debug_wireframe() const { return debug_wireframe_; }
void DCTerrain::set_lod_mode(int p_mode) {
LodMode new_mode = (p_mode == 0) ? LOD_EAGER_FADE : LOD_BLOCKING_SWAP;
if (new_mode == lod_mode_) return;
lod_mode_ = new_mode;
octree_clipmap_.lod_mode = new_mode;
octree_clipmap_.clear_pre_mesh_cache();
rebuild();
}
int DCTerrain::get_lod_mode() const {
return static_cast<int>(lod_mode_);
}
void DCTerrain::set_lod_frozen(bool p_frozen) {
lod_frozen_ = p_frozen;
}
bool DCTerrain::get_lod_frozen() const {
return lod_frozen_;
}
void DCTerrain::debug_kick_lod() {
// Diagnostic: force ONE LOD re-traversal without freezing or moving the camera.
// Isolates whether a pure invalidate_settled() advances refinement (the way the
// wall-climb/lizard un-freeze does via its camera-snap dead-zone trip). Also
// prints a fresh octree snapshot so the effect of each press is visible.
const ClipmapStats& st = octree_clipmap_.get_stats();
octree_clipmap_.invalidate_settled();
UtilityFunctions::print("[LODKICK] invalidated — before: settled=", st.settled,
" leaves=", st.leaf_count, " pending=", st.pending_count,
" blocked=", st.blocked_count, " frozen=", lod_frozen_);
}
// ---------------------------------------------------------------------------
// _bind_methods — expose everything to Godot
// ---------------------------------------------------------------------------
void DCTerrain::_bind_methods() {
// rebuild()
ClassDB::bind_method(D_METHOD("rebuild"), &DCTerrain::rebuild);
ClassDB::bind_method(D_METHOD("warmup_shaders"), &DCTerrain::warmup_shaders);
// ANSWERABLE, not just printed. A gate cannot see push_error, and the
// built-in noise generator renders a plausible landscape -- so without this
// there is no way for a test, or a map script, to ask whether the terrain
// on screen is the one the assigned generator describes.
ClassDB::bind_method(D_METHOD("is_using_builtin_generator"), &DCTerrain::is_using_builtin_generator);
ClassDB::bind_method(D_METHOD("bake_volume_mesh", "glsl", "origin", "voxel_size", "cells"), &DCTerrain::bake_volume_mesh);
ClassDB::bind_method(D_METHOD("get_scalar_query_counts"), &DCTerrain::get_scalar_query_counts);
ClassDB::bind_method(D_METHOD("reset_scalar_query_counts"), &DCTerrain::reset_scalar_query_counts);
ClassDB::bind_method(D_METHOD("sample_density", "position"), &DCTerrain::sample_density);
ClassDB::bind_method(D_METHOD("debug_gpu_density", "origin", "voxel_size", "cells"), &DCTerrain::debug_gpu_density);
ClassDB::bind_method(D_METHOD("debug_collision_faces", "center", "radius"), &DCTerrain::debug_collision_faces);
ClassDB::bind_method(D_METHOD("debug_chunk_info", "position"), &DCTerrain::debug_chunk_info);
ClassDB::bind_method(D_METHOD("debug_visual_faces", "center", "radius"), &DCTerrain::debug_visual_faces);
ClassDB::bind_method(D_METHOD("build_stamp"), &DCTerrain::build_stamp);
// SDF collision API
ClassDB::bind_method(D_METHOD("get_distance", "position"), &DCTerrain::get_distance);
ClassDB::bind_method(D_METHOD("get_surface_normal", "position"), &DCTerrain::get_surface_normal);
ClassDB::bind_method(D_METHOD("get_capsule_push", "position", "radius", "height"), &DCTerrain::get_capsule_push);
// -- Generator resource (optional override) --
ADD_GROUP("Generator", "");
ClassDB::bind_method(D_METHOD("set_generator", "generator"), &DCTerrain::set_generator);
ClassDB::bind_method(D_METHOD("get_generator"), &DCTerrain::get_generator);
ADD_PROPERTY(PropertyInfo(Variant::OBJECT, "generator", PROPERTY_HINT_RESOURCE_TYPE, "DCTerrainGenerator"),
"set_generator", "get_generator");
// -- Noise group --
ADD_GROUP("Noise", "");
ClassDB::bind_method(D_METHOD("set_use_noise", "value"), &DCTerrain::set_use_noise);
ClassDB::bind_method(D_METHOD("get_use_noise"), &DCTerrain::get_use_noise);
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "use_noise"), "set_use_noise", "get_use_noise");
ClassDB::bind_method(D_METHOD("set_force_direct_eval", "value"), &DCTerrain::set_force_direct_eval);
ClassDB::bind_method(D_METHOD("get_force_direct_eval"), &DCTerrain::get_force_direct_eval);
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "force_direct_eval"), "set_force_direct_eval", "get_force_direct_eval");
ClassDB::bind_method(D_METHOD("set_csg_refinement", "value"), &DCTerrain::set_csg_refinement);
ClassDB::bind_method(D_METHOD("get_csg_refinement"), &DCTerrain::get_csg_refinement);
ADD_PROPERTY(PropertyInfo(Variant::INT, "csg_refinement", PROPERTY_HINT_RANGE, "1,4"), "set_csg_refinement", "get_csg_refinement");
ClassDB::bind_method(D_METHOD("set_use_gpu", "value"), &DCTerrain::set_use_gpu);
ClassDB::bind_method(D_METHOD("get_use_gpu"), &DCTerrain::get_use_gpu);
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "use_gpu"), "set_use_gpu", "get_use_gpu");
ClassDB::bind_method(D_METHOD("set_seed", "value"), &DCTerrain::set_seed);
ClassDB::bind_method(D_METHOD("get_seed"), &DCTerrain::get_seed);
ADD_PROPERTY(PropertyInfo(Variant::INT, "seed"), "set_seed", "get_seed");
ClassDB::bind_method(D_METHOD("set_primary_frequency", "value"), &DCTerrain::set_primary_frequency);
ClassDB::bind_method(D_METHOD("get_primary_frequency"), &DCTerrain::get_primary_frequency);
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "primary_frequency", PROPERTY_HINT_RANGE, "0.001,1.0,0.001"), "set_primary_frequency", "get_primary_frequency");
ClassDB::bind_method(D_METHOD("set_primary_strength", "value"), &DCTerrain::set_primary_strength);
ClassDB::bind_method(D_METHOD("get_primary_strength"), &DCTerrain::get_primary_strength);
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "primary_strength", PROPERTY_HINT_RANGE, "0.0,5.0,0.01"), "set_primary_strength", "get_primary_strength");
ClassDB::bind_method(D_METHOD("set_tunnel_frequency", "value"), &DCTerrain::set_tunnel_frequency);
ClassDB::bind_method(D_METHOD("get_tunnel_frequency"), &DCTerrain::get_tunnel_frequency);
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "tunnel_frequency", PROPERTY_HINT_RANGE, "0.001,1.0,0.001"), "set_tunnel_frequency", "get_tunnel_frequency");
ClassDB::bind_method(D_METHOD("set_tunnel_strength", "value"), &DCTerrain::set_tunnel_strength);
ClassDB::bind_method(D_METHOD("get_tunnel_strength"), &DCTerrain::get_tunnel_strength);
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "tunnel_strength", PROPERTY_HINT_RANGE, "0.0,5.0,0.01"), "set_tunnel_strength", "get_tunnel_strength");
ClassDB::bind_method(D_METHOD("set_tunnel_radius", "value"), &DCTerrain::set_tunnel_radius);
ClassDB::bind_method(D_METHOD("get_tunnel_radius"), &DCTerrain::get_tunnel_radius);
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "tunnel_radius", PROPERTY_HINT_RANGE, "0.0,2.0,0.01"), "set_tunnel_radius", "get_tunnel_radius");
ClassDB::bind_method(D_METHOD("set_ridge_frequency", "value"), &DCTerrain::set_ridge_frequency);
ClassDB::bind_method(D_METHOD("get_ridge_frequency"), &DCTerrain::get_ridge_frequency);
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "ridge_frequency", PROPERTY_HINT_RANGE, "0.001,1.0,0.001"), "set_ridge_frequency", "get_ridge_frequency");
ClassDB::bind_method(D_METHOD("set_ridge_strength", "value"), &DCTerrain::set_ridge_strength);
ClassDB::bind_method(D_METHOD("get_ridge_strength"), &DCTerrain::get_ridge_strength);
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "ridge_strength", PROPERTY_HINT_RANGE, "0.0,5.0,0.01"), "set_ridge_strength", "get_ridge_strength");
ClassDB::bind_method(D_METHOD("set_warp_frequency", "value"), &DCTerrain::set_warp_frequency);
ClassDB::bind_method(D_METHOD("get_warp_frequency"), &DCTerrain::get_warp_frequency);
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "warp_frequency", PROPERTY_HINT_RANGE, "0.001,1.0,0.001"), "set_warp_frequency", "get_warp_frequency");
ClassDB::bind_method(D_METHOD("set_warp_strength", "value"), &DCTerrain::set_warp_strength);
ClassDB::bind_method(D_METHOD("get_warp_strength"), &DCTerrain::get_warp_strength);
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "warp_strength", PROPERTY_HINT_RANGE, "0.0,30.0,0.1"), "set_warp_strength", "get_warp_strength");
ClassDB::bind_method(D_METHOD("set_detail_frequency", "value"), &DCTerrain::set_detail_frequency);
ClassDB::bind_method(D_METHOD("get_detail_frequency"), &DCTerrain::get_detail_frequency);
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "detail_frequency", PROPERTY_HINT_RANGE, "0.001,1.0,0.001"), "set_detail_frequency", "get_detail_frequency");
ClassDB::bind_method(D_METHOD("set_detail_strength", "value"), &DCTerrain::set_detail_strength);
ClassDB::bind_method(D_METHOD("get_detail_strength"), &DCTerrain::get_detail_strength);
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "detail_strength", PROPERTY_HINT_RANGE, "0.0,2.0,0.01"), "set_detail_strength", "get_detail_strength");
// -- Terrain Shape group --
ADD_GROUP("Terrain Shape", "");
ClassDB::bind_method(D_METHOD("set_ground_bias", "value"), &DCTerrain::set_ground_bias);
ClassDB::bind_method(D_METHOD("get_ground_bias"), &DCTerrain::get_ground_bias);
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "ground_bias", PROPERTY_HINT_RANGE, "-1.0,1.0,0.001"), "set_ground_bias", "get_ground_bias");
ClassDB::bind_method(D_METHOD("set_solid_floor_depth", "value"), &DCTerrain::set_solid_floor_depth);
ClassDB::bind_method(D_METHOD("get_solid_floor_depth"), &DCTerrain::get_solid_floor_depth);
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "solid_floor_depth", PROPERTY_HINT_RANGE, "0.0,100.0,0.5"), "set_solid_floor_depth", "get_solid_floor_depth");
ClassDB::bind_method(D_METHOD("set_sky_height", "value"), &DCTerrain::set_sky_height);
ClassDB::bind_method(D_METHOD("get_sky_height"), &DCTerrain::get_sky_height);
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "sky_height", PROPERTY_HINT_RANGE, "0.0,100.0,0.5"), "set_sky_height", "get_sky_height");
ClassDB::bind_method(D_METHOD("set_flat_center_radius", "value"), &DCTerrain::set_flat_center_radius);
ClassDB::bind_method(D_METHOD("get_flat_center_radius"), &DCTerrain::get_flat_center_radius);
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "flat_center_radius", PROPERTY_HINT_RANGE, "0.0,50.0,0.5"), "set_flat_center_radius", "get_flat_center_radius");
ClassDB::bind_method(D_METHOD("set_terrain_size", "value"), &DCTerrain::set_terrain_size);
ClassDB::bind_method(D_METHOD("get_terrain_size"), &DCTerrain::get_terrain_size);
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "terrain_size", PROPERTY_HINT_RANGE, "10.0,100000.0,1.0"), "set_terrain_size", "get_terrain_size");
// -- Voronoi group --
ADD_GROUP("Voronoi Floors", "");
ClassDB::bind_method(D_METHOD("set_voronoi_cell_count", "value"), &DCTerrain::set_voronoi_cell_count);
ClassDB::bind_method(D_METHOD("get_voronoi_cell_count"), &DCTerrain::get_voronoi_cell_count);
ADD_PROPERTY(PropertyInfo(Variant::INT, "voronoi_cell_count", PROPERTY_HINT_RANGE, "0,20,1"), "set_voronoi_cell_count", "get_voronoi_cell_count");
ClassDB::bind_method(D_METHOD("set_floor_strength", "value"), &DCTerrain::set_floor_strength);
ClassDB::bind_method(D_METHOD("get_floor_strength"), &DCTerrain::get_floor_strength);
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "floor_strength", PROPERTY_HINT_RANGE, "0.0,3.0,0.01"), "set_floor_strength", "get_floor_strength");
ClassDB::bind_method(D_METHOD("set_floor_height_min", "value"), &DCTerrain::set_floor_height_min);
ClassDB::bind_method(D_METHOD("get_floor_height_min"), &DCTerrain::get_floor_height_min);
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "floor_height_min", PROPERTY_HINT_RANGE, "-50.0,50.0,0.5"), "set_floor_height_min", "get_floor_height_min");
ClassDB::bind_method(D_METHOD("set_floor_height_max", "value"), &DCTerrain::set_floor_height_max);
ClassDB::bind_method(D_METHOD("get_floor_height_max"), &DCTerrain::get_floor_height_max);
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "floor_height_max", PROPERTY_HINT_RANGE, "-50.0,50.0,0.5"), "set_floor_height_max", "get_floor_height_max");
ClassDB::bind_method(D_METHOD("set_room_height", "value"), &DCTerrain::set_room_height);
ClassDB::bind_method(D_METHOD("get_room_height"), &DCTerrain::get_room_height);
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "room_height", PROPERTY_HINT_RANGE, "1.0,30.0,0.5"), "set_room_height", "get_room_height");
// -- Display group --
ADD_GROUP("Display", "");
ClassDB::bind_method(D_METHOD("set_terrain_color", "color"), &DCTerrain::set_terrain_color);
ClassDB::bind_method(D_METHOD("get_terrain_color"), &DCTerrain::get_terrain_color);
ADD_PROPERTY(PropertyInfo(Variant::COLOR, "terrain_color"), "set_terrain_color", "get_terrain_color");
ClassDB::bind_method(D_METHOD("set_custom_material", "material"), &DCTerrain::set_custom_material);
ClassDB::bind_method(D_METHOD("get_custom_material"), &DCTerrain::get_custom_material);
ADD_PROPERTY(PropertyInfo(Variant::OBJECT, "custom_material", PROPERTY_HINT_RESOURCE_TYPE, "Material"),
"set_custom_material", "get_custom_material");
ClassDB::bind_method(D_METHOD("set_voxel_size", "value"), &DCTerrain::set_voxel_size);
ClassDB::bind_method(D_METHOD("get_voxel_size"), &DCTerrain::get_voxel_size);
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "voxel_size", PROPERTY_HINT_RANGE, "0.1,10.0,0.1"), "set_voxel_size", "get_voxel_size");
ClassDB::bind_method(D_METHOD("set_octree_depth", "value"), &DCTerrain::set_octree_depth);
ClassDB::bind_method(D_METHOD("get_octree_depth"), &DCTerrain::get_octree_depth);
ADD_PROPERTY(PropertyInfo(Variant::INT, "octree_depth", PROPERTY_HINT_RANGE, "3,20,1"), "set_octree_depth", "get_octree_depth");
ClassDB::bind_method(D_METHOD("set_seamless_lod", "value"), &DCTerrain::set_seamless_lod);
ClassDB::bind_method(D_METHOD("get_seamless_lod"), &DCTerrain::get_seamless_lod);
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "seamless_lod"), "set_seamless_lod", "get_seamless_lod");
ClassDB::bind_method(D_METHOD("set_spherical", "value"), &DCTerrain::set_spherical);
ClassDB::bind_method(D_METHOD("get_spherical"), &DCTerrain::get_spherical);
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "spherical"), "set_spherical", "get_spherical");
ClassDB::bind_method(D_METHOD("set_floating_islands", "value"), &DCTerrain::set_floating_islands);
ClassDB::bind_method(D_METHOD("get_floating_islands"), &DCTerrain::get_floating_islands);
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "floating_islands"), "set_floating_islands", "get_floating_islands");
ClassDB::bind_method(D_METHOD("set_island_radius", "value"), &DCTerrain::set_island_radius);
ClassDB::bind_method(D_METHOD("get_island_radius"), &DCTerrain::get_island_radius);
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "island_radius", PROPERTY_HINT_RANGE, "10.0,500.0,1.0"), "set_island_radius", "get_island_radius");
ClassDB::bind_method(D_METHOD("set_island_depth_ratio", "value"), &DCTerrain::set_island_depth_ratio);
ClassDB::bind_method(D_METHOD("get_island_depth_ratio"), &DCTerrain::get_island_depth_ratio);
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "island_depth_ratio", PROPERTY_HINT_RANGE, "0.5,10.0,0.1"), "set_island_depth_ratio", "get_island_depth_ratio");
ClassDB::bind_method(D_METHOD("set_island_spacing", "value"), &DCTerrain::set_island_spacing);
ClassDB::bind_method(D_METHOD("get_island_spacing"), &DCTerrain::get_island_spacing);
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "island_spacing", PROPERTY_HINT_RANGE, "50.0,1000.0,10.0"), "set_island_spacing", "get_island_spacing");
ClassDB::bind_method(D_METHOD("set_planet_radius", "value"), &DCTerrain::set_planet_radius);
ClassDB::bind_method(D_METHOD("get_planet_radius"), &DCTerrain::get_planet_radius);
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "planet_radius", PROPERTY_HINT_RANGE, "10.0,1000000.0,100.0"), "set_planet_radius", "get_planet_radius");
ClassDB::bind_method(D_METHOD("set_max_noise_octaves", "value"), &DCTerrain::set_max_noise_octaves);
ClassDB::bind_method(D_METHOD("get_max_noise_octaves"), &DCTerrain::get_max_noise_octaves);
ADD_PROPERTY(PropertyInfo(Variant::INT, "max_noise_octaves", PROPERTY_HINT_RANGE, "0,20,1"), "set_max_noise_octaves", "get_max_noise_octaves");
ClassDB::bind_method(D_METHOD("set_max_ridge_octaves", "value"), &DCTerrain::set_max_ridge_octaves);
ClassDB::bind_method(D_METHOD("get_max_ridge_octaves"), &DCTerrain::get_max_ridge_octaves);
ADD_PROPERTY(PropertyInfo(Variant::INT, "max_ridge_octaves", PROPERTY_HINT_RANGE, "0,20,1"), "set_max_ridge_octaves", "get_max_ridge_octaves");
ClassDB::bind_method(D_METHOD("set_collision_mode", "value"), &DCTerrain::set_collision_mode);
ClassDB::bind_method(D_METHOD("get_collision_mode"), &DCTerrain::get_collision_mode);
ADD_PROPERTY(PropertyInfo(Variant::INT, "collision_mode", PROPERTY_HINT_ENUM, "Trimesh,SDF,Both"), "set_collision_mode", "get_collision_mode");
ClassDB::bind_method(D_METHOD("set_collision_lod_max", "value"), &DCTerrain::set_collision_lod_max);
ClassDB::bind_method(D_METHOD("get_collision_lod_max"), &DCTerrain::get_collision_lod_max);
ADD_PROPERTY(PropertyInfo(Variant::INT, "collision_lod_max", PROPERTY_HINT_RANGE, "0,4,1"), "set_collision_lod_max", "get_collision_lod_max");
ClassDB::bind_method(D_METHOD("set_collision_distance", "value"), &DCTerrain::set_collision_distance);
ClassDB::bind_method(D_METHOD("get_collision_distance"), &DCTerrain::get_collision_distance);
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "collision_distance", PROPERTY_HINT_RANGE, "10.0,500.0,5.0"), "set_collision_distance", "get_collision_distance");
// -- Stats (read-only methods for HUD) --
ClassDB::bind_method(D_METHOD("get_chunk_count"), &DCTerrain::get_chunk_count);
ClassDB::bind_method(D_METHOD("get_active_chunk_count"), &DCTerrain::get_active_chunk_count);
ClassDB::bind_method(D_METHOD("get_pending_count"), &DCTerrain::get_pending_count);
ClassDB::bind_method(D_METHOD("get_collision_queue_size"), &DCTerrain::get_collision_queue_size);
ClassDB::bind_method(D_METHOD("get_splits_this_frame"), &DCTerrain::get_splits_this_frame);
// -- Terrain editing --
ClassDB::bind_method(D_METHOD("edit_terrain", "position", "radius", "strength", "op", "interactive", "material_id"), &DCTerrain::edit_terrain, DEFVAL(0), DEFVAL(true), DEFVAL(0));
ClassDB::bind_method(D_METHOD("edit_terrain_box", "position", "half_extents", "strength", "interactive", "material_id", "persistent"), &DCTerrain::edit_terrain_box, DEFVAL(true), DEFVAL(0), DEFVAL(false));
ClassDB::bind_method(D_METHOD("edit_terrain_convex", "planes", "center", "radius", "strength", "interactive", "material_id", "face_proj"), &DCTerrain::edit_terrain_convex, DEFVAL(true), DEFVAL(0), DEFVAL(PackedInt32Array()));
ClassDB::bind_method(D_METHOD("stamp_mesh_obj", "obj_path", "position", "scale", "strength", "interactive", "material_id"), &DCTerrain::stamp_mesh_obj, DEFVAL(true), DEFVAL(0));
ClassDB::bind_method(D_METHOD("stamp_mesh_arrays", "vertices", "indices", "position", "scale", "strength", "interactive", "material_id", "persistent"), &DCTerrain::stamp_mesh_arrays, DEFVAL(true), DEFVAL(0), DEFVAL(false));
ClassDB::bind_method(D_METHOD("debug_sdf_at", "position"), &DCTerrain::debug_sdf_at);
ClassDB::bind_method(D_METHOD("clear_edits"), &DCTerrain::clear_edits);
ClassDB::bind_method(D_METHOD("clear_landscape_edits"), &DCTerrain::clear_landscape_edits);
ClassDB::bind_method(D_METHOD("get_edit_count"), &DCTerrain::get_edit_count);
ClassDB::bind_method(D_METHOD("pick_brush", "point"), &DCTerrain::pick_brush);
ClassDB::bind_method(D_METHOD("get_brush_center", "idx"), &DCTerrain::get_brush_center);
ClassDB::bind_method(D_METHOD("get_brush_radius", "idx"), &DCTerrain::get_brush_radius);
ClassDB::bind_method(D_METHOD("get_brush_hull_triangles", "idx"), &DCTerrain::get_brush_hull_triangles);
ClassDB::bind_method(D_METHOD("lift_brush", "idx"), &DCTerrain::lift_brush);
ClassDB::bind_method(D_METHOD("move_brush", "idx", "delta"), &DCTerrain::move_brush);
ClassDB::bind_method(D_METHOD("get_edits_as_array"), &DCTerrain::get_edits_as_array);
ClassDB::bind_method(D_METHOD("load_edits_from_array", "edits"), &DCTerrain::load_edits_from_array);
// -- CSG primitives --
ClassDB::bind_method(D_METHOD("add_csg_primitive", "type", "op", "group", "position", "size"),
&DCTerrain::add_csg_primitive);
ClassDB::bind_method(D_METHOD("clear_csg_primitives"), &DCTerrain::clear_csg_primitives);
ClassDB::bind_method(D_METHOD("get_csg_primitive_count"), &DCTerrain::get_csg_primitive_count);
ClassDB::bind_method(D_METHOD("set_csg_primitive_position", "index", "position"),
&DCTerrain::set_csg_primitive_position);
ClassDB::bind_method(D_METHOD("set_csg_primitive_size", "index", "size"),
&DCTerrain::set_csg_primitive_size);
ClassDB::bind_method(D_METHOD("remove_csg_primitive", "index"),
&DCTerrain::remove_csg_primitive);
// -- LOD group --
ADD_GROUP("LOD", "");
ClassDB::bind_method(D_METHOD("set_lod_mode", "mode"), &DCTerrain::set_lod_mode);
ClassDB::bind_method(D_METHOD("get_lod_mode"), &DCTerrain::get_lod_mode);
ADD_PROPERTY(PropertyInfo(Variant::INT, "lod_mode", PROPERTY_HINT_ENUM,
"Eager Fade,Blocking Swap"), "set_lod_mode", "get_lod_mode");
ClassDB::bind_method(D_METHOD("set_lod_factor", "value"), &DCTerrain::set_lod_factor);
ClassDB::bind_method(D_METHOD("get_lod_factor"), &DCTerrain::get_lod_factor);
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "lod_factor", PROPERTY_HINT_RANGE, "1.0,8.0,0.1"), "set_lod_factor", "get_lod_factor");
ClassDB::bind_method(D_METHOD("set_hysteresis", "value"), &DCTerrain::set_hysteresis);
ClassDB::bind_method(D_METHOD("get_hysteresis"), &DCTerrain::get_hysteresis);
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "hysteresis", PROPERTY_HINT_RANGE, "1.0,2.0,0.05"), "set_hysteresis", "get_hysteresis");
ClassDB::bind_method(D_METHOD("set_fade_speed", "value"), &DCTerrain::set_fade_speed);
ClassDB::bind_method(D_METHOD("get_fade_speed"), &DCTerrain::get_fade_speed);
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "fade_speed", PROPERTY_HINT_RANGE, "0.5,20.0,0.5"), "set_fade_speed", "get_fade_speed");
ClassDB::bind_method(D_METHOD("set_normal_perturbation", "value"), &DCTerrain::set_normal_perturbation);
ClassDB::bind_method(D_METHOD("get_normal_perturbation"), &DCTerrain::get_normal_perturbation);
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "normal_perturbation", PROPERTY_HINT_RANGE, "0.0,0.3,0.01"), "set_normal_perturbation", "get_normal_perturbation");
ClassDB::bind_method(D_METHOD("set_screen_split_threshold", "value"), &DCTerrain::set_screen_split_threshold);
ClassDB::bind_method(D_METHOD("get_screen_split_threshold"), &DCTerrain::get_screen_split_threshold);
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "screen_split_threshold", PROPERTY_HINT_RANGE, "0.1,4.0,0.1"), "set_screen_split_threshold", "get_screen_split_threshold");
ClassDB::bind_method(D_METHOD("set_use_screen_space_error", "value"), &DCTerrain::set_use_screen_space_error);
ClassDB::bind_method(D_METHOD("get_use_screen_space_error"), &DCTerrain::get_use_screen_space_error);
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "use_screen_space_error"), "set_use_screen_space_error", "get_use_screen_space_error");
ClassDB::bind_method(D_METHOD("set_lod_frozen", "frozen"), &DCTerrain::set_lod_frozen);
ClassDB::bind_method(D_METHOD("get_lod_frozen"), &DCTerrain::get_lod_frozen);
ClassDB::bind_method(D_METHOD("debug_kick_lod"), &DCTerrain::debug_kick_lod);
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "lod_frozen"), "set_lod_frozen", "get_lod_frozen");
// -- Mesh Quality group --
ADD_GROUP("Mesh Quality", "use_");
ClassDB::bind_method(D_METHOD("set_use_adaptive_qef", "value"), &DCTerrain::set_use_adaptive_qef);
ClassDB::bind_method(D_METHOD("get_use_adaptive_qef"), &DCTerrain::get_use_adaptive_qef);
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "use_adaptive_qef"), "set_use_adaptive_qef", "get_use_adaptive_qef");
ClassDB::bind_method(D_METHOD("set_adaptive_arch_lod", "value"), &DCTerrain::set_adaptive_arch_lod);
ClassDB::bind_method(D_METHOD("get_adaptive_arch_lod"), &DCTerrain::get_adaptive_arch_lod);
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "adaptive_arch_lod"), "set_adaptive_arch_lod", "get_adaptive_arch_lod");
ClassDB::bind_method(D_METHOD("set_arch_min_lod", "lod"), &DCTerrain::set_arch_min_lod);
ClassDB::bind_method(D_METHOD("get_arch_min_lod"), &DCTerrain::get_arch_min_lod);
ADD_PROPERTY(PropertyInfo(Variant::INT, "arch_min_lod"), "set_arch_min_lod", "get_arch_min_lod");
ClassDB::bind_method(D_METHOD("set_texture_atlas", "atlas"), &DCTerrain::set_texture_atlas);
ClassDB::bind_method(D_METHOD("set_glow_atlas", "atlas"), &DCTerrain::set_glow_atlas);
ClassDB::bind_method(D_METHOD("set_norm_atlas", "atlas"), &DCTerrain::set_norm_atlas);
ClassDB::bind_method(D_METHOD("set_projection_table", "table"), &DCTerrain::set_projection_table);
ClassDB::bind_method(D_METHOD("set_use_textures", "value"), &DCTerrain::set_use_textures);
ClassDB::bind_method(D_METHOD("get_use_textures"), &DCTerrain::get_use_textures);
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "use_textures"), "set_use_textures", "get_use_textures");
ClassDB::bind_method(D_METHOD("set_use_shortest_diagonal", "value"), &DCTerrain::set_use_shortest_diagonal);
ClassDB::bind_method(D_METHOD("get_use_shortest_diagonal"), &DCTerrain::get_use_shortest_diagonal);
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "use_shortest_diagonal"), "set_use_shortest_diagonal", "get_use_shortest_diagonal");
ClassDB::bind_method(D_METHOD("set_use_mesh_simplify", "value"), &DCTerrain::set_use_mesh_simplify);
ClassDB::bind_method(D_METHOD("get_use_mesh_simplify"), &DCTerrain::get_use_mesh_simplify);
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "use_mesh_simplify"), "set_use_mesh_simplify", "get_use_mesh_simplify");
// -- Debug group --
ADD_GROUP("Debug", "debug_");
ClassDB::bind_method(D_METHOD("set_debug_logging", "value"), &DCTerrain::set_debug_logging);
ClassDB::bind_method(D_METHOD("get_debug_logging"), &DCTerrain::get_debug_logging);
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "debug_logging"), "set_debug_logging", "get_debug_logging");
ClassDB::bind_method(D_METHOD("set_debug_lod_colors", "value"), &DCTerrain::set_debug_lod_colors);
ClassDB::bind_method(D_METHOD("get_debug_lod_colors"), &DCTerrain::get_debug_lod_colors);
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "debug_lod_colors"), "set_debug_lod_colors", "get_debug_lod_colors");
ClassDB::bind_method(D_METHOD("set_debug_seams", "value"), &DCTerrain::set_debug_seams);
ClassDB::bind_method(D_METHOD("get_debug_seams"), &DCTerrain::get_debug_seams);
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "debug_seams"), "set_debug_seams", "get_debug_seams");
ClassDB::bind_method(D_METHOD("set_debug_clipmap", "value"), &DCTerrain::set_debug_clipmap);
ClassDB::bind_method(D_METHOD("get_debug_clipmap"), &DCTerrain::get_debug_clipmap);
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "debug_clipmap"), "set_debug_clipmap", "get_debug_clipmap");
ClassDB::bind_method(D_METHOD("set_debug_wireframe", "value"), &DCTerrain::set_debug_wireframe);
ClassDB::bind_method(D_METHOD("get_debug_wireframe"), &DCTerrain::get_debug_wireframe);
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "debug_wireframe"), "set_debug_wireframe", "get_debug_wireframe");
ClassDB::bind_method(D_METHOD("set_debug_edits", "value"), &DCTerrain::set_debug_edits);
ClassDB::bind_method(D_METHOD("get_debug_edits"), &DCTerrain::get_debug_edits);
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "debug_edits"), "set_debug_edits", "get_debug_edits");
}
} // namespace godot