#ifndef DC_GPU_THREAD_H
#define DC_GPU_THREAD_H
// Dedicated GPU thread for compute shader dispatch.
//
// Architecture:
// - Owns a std::thread that creates the local RenderingDevice on startup
// (satisfying Godot 4.6 thread affinity — all RD operations must happen
// on the thread that created the device).
// - Workers push GpuFillRequest structs into a queue.
// - GPU thread waits on a condition variable, accumulates up to BATCH_SIZE
// requests (with a short wait window), then processes them.
// - Each request carries a std::promise<bool> so the worker blocks on
// the future until its grid is filled.
// - Within a batch: update params for each, dispatch all in one compute
// list, one submit+sync, read back all results.
//
// This replaces the old mutex-based pattern where workers would try_lock
// a compute_mutex_ and fall back to CPU. Now workers always submit to the
// GPU thread and wait (or fall back to CPU if the GPU thread isn't available).
#include "dc_compute.h"
#include <cstdlib>
#include "dc_gpu_mesh_pipeline.h"
#include "dc_density.h"
#include "dc_density_grid.h"
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>
#include <future>
#include <atomic>
#include <vector>
#include <chrono>
#include <cstdio>
struct GpuFillRequest {
DensityGrid* grid;
const DCDensity* density;
const char* custom_glsl = nullptr; // optional: proc-nodes compiled shader
const float* custom_data = nullptr; // optional: baked custom data (binding 13)
int custom_data_n = 0;
uint32_t custom_data_version = 0; // content version; see DCGenerator
const void* const* noise_volumes = nullptr; // optional: 8 sampler3D volumes (RGBA half)
int noise_dim = 0; // side length (16 for the GPU Gems volumes)
uint32_t noise_version = 0;
const std::vector<TerrainEdit>* edits = nullptr; // terrain edits for GPU evaluation
bool wants_gpu_mesh = false; // request GPU mesh generation (skip CPU DC)
DCMeshData* mesh_output = nullptr; // where to write GPU-generated mesh
float cell_origin[3] = {}; // world-space chunk origin (for GPU mesh)
float voxel_size = 0.0f; // voxel size (for GPU mesh)
uint32_t mesh_quality_flags = 0; // quality flags (for GPU mesh)
std::promise<bool> result;
};
class DCGpuThread {
public:
static constexpr size_t BATCH_SIZE = 4;
static constexpr int BATCH_WAIT_US = 1000; // 1ms accumulation window
// Threshold gate: a chunk only bakes a settled-edit brick if its CULLED edit
// count reaches this — the bake is a full extra GPU dispatch, only worth it
// where many overlapping edits make the per-sample edit loop the bottleneck.
// Below it, edits apply inline (no bake, no 2x dispatch). For a normally
// edited world (p50 ~6 culled edits/chunk) almost nothing crosses this, so
// the bake-every-chunk cost of plain 1b is removed for the common case.
static constexpr int BRICK_EDIT_THRESHOLD = 16;
DCGpuThread() = default;
~DCGpuThread() { stop(); }
// Non-copyable, non-movable
DCGpuThread(const DCGpuThread&) = delete;
DCGpuThread& operator=(const DCGpuThread&) = delete;
// Start the GPU thread. Call from main thread (e.g., _ready()).
void start() {
if (running_.load()) return;
running_.store(true);
gpu_available_.store(false);
thread_ = std::thread(&DCGpuThread::_run, this);
}
// Stop the GPU thread and wait for it to finish.
void stop() {
if (!running_.load()) return;
running_.store(false);
cv_.notify_all();
if (thread_.joinable()) {
thread_.join();
}
// Drain any remaining requests (shouldn't happen, but be safe)
std::lock_guard<std::mutex> lock(mutex_);
while (!requests_.empty()) {
requests_.front().result.set_value(false);
requests_.pop();
}
}
// Submit a grid fill request. Blocks the caller until the GPU thread
// processes it. Returns true if GPU filled the grid, false on failure.
// Thread-safe — multiple workers can call concurrently.
bool submit(DensityGrid& grid, const DCDensity& density, const char* custom_glsl = nullptr,
const std::vector<TerrainEdit>* edits = nullptr,
DCMeshData* mesh_output = nullptr,
const float* cell_origin = nullptr, float voxel_size = 0.0f,
uint32_t mesh_quality_flags = 0,
const float* custom_data = nullptr, int custom_data_n = 0,
uint32_t custom_data_version = 0,
const void* const* noise_volumes = nullptr, int noise_dim = 0,
uint32_t noise_version = 0) {
if (!running_.load() || !gpu_available_.load()) {
return false;
}
std::promise<bool> promise;
std::future<bool> future = promise.get_future();
{
std::lock_guard<std::mutex> lock(mutex_);
GpuFillRequest req;
req.grid = &grid;
req.density = &density;
req.custom_glsl = custom_glsl;
req.custom_data = custom_data;
req.custom_data_n = custom_data_n;
req.custom_data_version = custom_data_version;
req.noise_volumes = noise_volumes;
req.noise_dim = noise_dim;
req.noise_version = noise_version;
req.edits = edits;
if (mesh_output && cell_origin) {
req.wants_gpu_mesh = true;
req.mesh_output = mesh_output;
req.cell_origin[0] = cell_origin[0];
req.cell_origin[1] = cell_origin[1];
req.cell_origin[2] = cell_origin[2];
req.voxel_size = voxel_size;
req.mesh_quality_flags = mesh_quality_flags;
}
req.result = std::move(promise);
requests_.push(std::move(req));
}
cv_.notify_one();
// Block until GPU thread processes our request
return future.get();
}
// Check if GPU is available (device created successfully).
bool is_available() const { return gpu_available_.load(); }
// Check if the thread is running.
bool is_running() const { return running_.load(); }
private:
std::thread thread_;
std::mutex mutex_;
std::condition_variable cv_;
std::queue<GpuFillRequest> requests_;
std::atomic<bool> running_{false};
std::atomic<bool> gpu_available_{false};
DCCompute compute_;
DCGpuMeshPipeline mesh_pipeline_;
bool mesh_pipeline_available_ = false;
const char* current_custom_glsl_ = nullptr; // track to avoid recompiling same shader
const void* const* current_noise_volumes_ = nullptr;
uint32_t current_noise_version_ = 0;
const float* current_custom_data_ = nullptr; // same trick: re-upload only on change
uint32_t current_custom_data_version_ = 0; // ...and on content version, for growth
void _run() {
// Create the RenderingDevice on THIS thread (thread affinity).
// All subsequent RD operations must happen here.
if (!compute_.initialize()) {
fprintf(stderr, "DCGpuThread: Failed to initialize GPU compute\n");
// Thread stays alive but gpu_available_ remains false,
// so submit() will return false immediately.
_drain_loop();
return;
}
// Initialize mesh pipeline on the same RenderingDevice
fprintf(stderr, "DCGpuThread: Initializing GPU mesh pipeline...\n");
mesh_pipeline_available_ = mesh_pipeline_.initialize(compute_.rd());
if (mesh_pipeline_available_) {
fprintf(stderr, "DCGpuThread: GPU mesh pipeline ready\n");
} else {
fprintf(stderr, "DCGpuThread: GPU mesh pipeline FAILED (CPU fallback for meshing)\n");
}
gpu_available_.store(true);
fprintf(stderr, "DCGpuThread: GPU compute ready, entering dispatch loop\n");
while (running_.load()) {
std::vector<GpuFillRequest> batch;
// Wait for at least one request
{
std::unique_lock<std::mutex> lock(mutex_);
cv_.wait_for(lock, std::chrono::milliseconds(50), [this] {
return !requests_.empty() || !running_.load();
});
if (!running_.load()) {
// Drain remaining
while (!requests_.empty()) {
requests_.front().result.set_value(false);
requests_.pop();
}
break;
}
if (requests_.empty()) continue;
// Pop the first request
batch.push_back(std::move(requests_.front()));
requests_.pop();
}
// Brief wait to accumulate more requests for batching
if (batch.size() < BATCH_SIZE) {
auto deadline = std::chrono::steady_clock::now() +
std::chrono::microseconds(BATCH_WAIT_US);
std::unique_lock<std::mutex> lock(mutex_);
while (batch.size() < BATCH_SIZE &&
std::chrono::steady_clock::now() < deadline) {
cv_.wait_until(lock, deadline, [this] {
return !requests_.empty() || !running_.load();
});
if (!running_.load()) break;
while (!requests_.empty() && batch.size() < BATCH_SIZE) {
batch.push_back(std::move(requests_.front()));
requests_.pop();
}
}
}
// Process the batch
_process_batch(batch);
}
// Cleanup GPU resources on this thread (thread affinity)
mesh_pipeline_.cleanup();
mesh_pipeline_available_ = false;
compute_.cleanup();
gpu_available_.store(false);
fprintf(stderr, "DCGpuThread: Shutdown complete\n");
}
// Drain loop for when GPU init failed — just reject all requests
void _drain_loop() {
while (running_.load()) {
std::unique_lock<std::mutex> lock(mutex_);
cv_.wait_for(lock, std::chrono::milliseconds(50), [this] {
return !requests_.empty() || !running_.load();
});
while (!requests_.empty()) {
requests_.front().result.set_value(false);
requests_.pop();
}
}
}
// Process a batch of requests. For batched dispatch:
// - All grids must have the same stride (they do — chunk_size is uniform)
// - Update params, dispatch, submit+sync once, then read back all
void _process_batch(std::vector<GpuFillRequest>& batch) {
if (batch.empty()) return;
// Apply custom shader only if it changed since last batch.
// custom_glsl points to cached_glsl_.c_str() which is stable
// until _recompile_glsl() runs on the main thread.
// Noise volumes BEFORE the shader compile: set_custom_shader rebuilds
// the uniform set, and the descriptors it writes must already point at
// the real volumes rather than the 1^3 placeholders.
if (batch[0].noise_version != current_noise_version_
|| batch[0].noise_volumes != current_noise_volumes_) {
compute_.set_noise_volumes(batch[0].noise_volumes, batch[0].noise_dim);
current_noise_volumes_ = batch[0].noise_volumes;
current_noise_version_ = batch[0].noise_version;
}
const char* glsl = batch[0].custom_glsl;
if (glsl != current_custom_glsl_) {
if (!compute_.set_custom_shader(glsl)) {
fprintf(stderr, "DCGpuThread: Custom shader failed, falling back\n");
for (auto& req : batch) req.result.set_value(false);
return;
}
current_custom_glsl_ = glsl;
// A new shader means a new uniform set, so force the custom data to
// be re-uploaded even if the generator handed back the same pointer.
current_custom_data_ = nullptr;
current_custom_data_version_ = 0u;
}
// Baked custom data, same change-detection contract as the shader above.
// Uploading reallocates the buffer and dirties the uniform set, so doing
// it per chunk would rebuild the set thousands of times a second.
//
// POINTER *OR* VERSION. The pointer alone was right while custom data
// was only ever replaced wholesale; it is wrong for a generator that
// GROWS its buffer, because a std::vector written to within its
// existing capacity keeps the same data() address and the upload is
// silently skipped -- new terrain on disk, old terrain on screen, no
// error anywhere. A tile arriving from a peer mid-session is that case.
// A/B SWITCH: DC_NOVER=1 ignores the content version and restores the
// pointer-only test this had before 2026-09-03. It also COUNTS the
// uploads, because "set_custom_data ran every batch" and "it ran twice"
// look identical from a frame graph and only one of them is a
// regression.
static const bool kNoVersion = (getenv("DC_NOVER") != nullptr);
static uint64_t s_uploads = 0;
const float* hb = batch[0].custom_data;
const uint32_t hv = kNoVersion ? current_custom_data_version_
: batch[0].custom_data_version;
if (hb != current_custom_data_ || hv != current_custom_data_version_) {
if ((++s_uploads % 16) == 1 || s_uploads < 4) {
fprintf(stderr, "DCGpuThread: custom-data upload #%llu (%d floats)\n",
(unsigned long long)s_uploads, batch[0].custom_data_n);
}
compute_.set_custom_data(hb, batch[0].custom_data_n);
current_custom_data_ = hb;
current_custom_data_version_ = hv;
}
// Process each request INDIVIDUALLY with ITS OWN culled edit list.
// Edits are culled per-chunk in dc_terrain.cpp, so they are NOT shared
// across a batch. The old code uploaded only batch[0].edits for the whole
// batch AND the multi-chunk path (fill_grid_batch) skipped the mesh stages
// entirely — so any chunk that got batched was carved with the wrong (or
// no) edits and showed pre-edit geometry until it happened to dispatch
// solo. That timing-dependent batching is the "CSG edits glitch in and
// out of the original chunk / rippling" bug. Per-request density+mesh with
// the right edits fixes it (one extra submit/sync per chunk — correctness
// over the broken batch optimization).
for (auto& req : batch) {
// This chunk's own culled edit list. Threshold gate: only DENSE
// chunks bake a brick; sparse chunks apply edits inline (no bake).
bool has_edits = req.edits && !req.edits->empty();
int n_edits = has_edits ? (int)req.edits->size() : 0;
bool use_brick = n_edits >= BRICK_EDIT_THRESHOLD;
DCCompute::EditUploadStats ustats = compute_.upload_edits(req.edits);
// THE CAP IS NOW LOUD. Both drop paths in upload_edits were silent —
// `break` at MAX_GPU_EDITS and `continue` at MAX_CONVEX_PLANES — so a
// chunk that overflowed rendered missing geometry with no error, and
// the amount varied with LOD and camera because the cap is applied
// PER CHUNK after culling. That is indistinguishable from a meshing
// bug, and it is why the overflow measured in the interior-decomposition
// oracle (2735 boxes at 0.25 m -> 2650 in one LOD6 chunk) could never
// have been noticed from in-game.
//
// Reported ONCE, with the chunk that did it. A per-chunk log would be
// thousands of lines a second; the first occurrence is what you act on,
// and the oracle's --assert-cap gate is what keeps it from recurring.
if (!ustats.complete()) {
static bool cap_warned = false;
if (!cap_warned) {
cap_warned = true;
// cell_origin + voxel_size, because that is what this struct
// actually carries: GpuFillRequest has no lod_level and no
// node_position (those live on CellRequest, one layer up). It
// loses nothing — the origin is WORLD space, so you can fly to
// it, and voxel_size is the LOD-scaled voxel, so the level is
// voxel_size / base.
fprintf(stderr,
"[DC] EDIT CAP EXCEEDED — geometry is MISSING in this chunk.\n"
" chunk origin (%.2f, %.2f, %.2f) voxel %.4f m\n"
" culled=%d uploaded=%d dropped_cap=%d dropped_planes=%d\n"
" A coarse chunk culls to nearly the whole map; raise\n"
" MAX_GPU_EDITS or feed coarse LODs a coarser decomposition.\n"
" (reported once)\n",
req.cell_origin[0], req.cell_origin[1], req.cell_origin[2],
req.voxel_size,
ustats.culled, ustats.uploaded,
ustats.dropped_cap, ustats.dropped_planes);
}
}
// Single-roundtrip path: density + mesh in one compute list
if (req.wants_gpu_mesh && mesh_pipeline_available_ && req.mesh_output) {
mesh_pipeline_.set_custom_sdf(glsl);
int grid_dims = req.grid->stride - 1;
// All buffer updates BEFORE compute_list_begin
compute_.prepare_density(*req.grid, *req.density);
// Bake settled edits into the brick, then mesh with HOT empty.
// Increment 1: everything settled -> output identical to applying
// the edits inline, proving the bake + trilinear composition.
// bake_brick's own submit/sync completes before edit_buf is
// cleared. No-edit chunks skip the bake and read a zero brick.
if (use_brick) {
compute_.bake_brick(req.grid->stride);
// STEP 2: KEEP edit_buf populated (do NOT empty it). The bake
// folded the edits into brick_delta, and the flags-bit1 guard
// (edge_intersect.glsl's dc_sample_field takes the brick OR
// the edits, never both — it moved there from noise_sdf.glsl
// when the two templates were given one contract)
// prevents double-counting — so the brick supplies density
// while edge_intersect's exact-Hermite override reads the
// convex planes from edit_buf and sharpens faces/edges through
// the brick (tests/test_brick.cpp). Dense chunks were soupy
// because emptying edit_buf here disabled that override.
compute_.set_brick_active(true);
} else {
// Inline: edits stay in edit_buf, applied in-shader. No bake,
// no 2x dispatch (sparse chunks + no-edit chunks).
compute_.set_brick_active(false);
}
DensityBuffers bufs;
bufs.density = compute_.density_buf();
bufs.params = compute_.params_buf();
bufs.voronoi = compute_.voronoi_buf();
bufs.perm = compute_.perm_buf();
bufs.edit = compute_.edit_buf();
bufs.brick = compute_.brick_buf();
bufs.convex_planes = compute_.convex_plane_buf();
bufs.convex_faceproj = compute_.convex_faceproj_buf();
bufs.bvh_nodes = compute_.bvh_node_buf();
bufs.bvh_tris = compute_.bvh_tri_buf();
bufs.bvh_indices = compute_.bvh_idx_buf();
bufs.custom_data = compute_.custom_data_buf();
bufs.noise_sampler_linear = compute_.noise_sampler_linear();
bufs.noise_sampler_nearest = compute_.noise_sampler_nearest();
for (int ni = 0; ni < 8; ni++) bufs.noise_tex[ni] = compute_.noise_tex(ni);
mesh_pipeline_.prepare_mesh(grid_dims, bufs);
// Single compute list: density + mesh pipeline
RenderingDevice* rd = compute_.rd();
int64_t cl = rd->compute_list_begin();
// Stage 1: density fill
compute_.dispatch_density(cl, req.grid->stride);
// Stages 2-11: mesh pipeline (bufs already built above)
bool dispatched = mesh_pipeline_.dispatch_mesh_stages(
cl, bufs, grid_dims,
req.cell_origin, req.voxel_size,
req.mesh_quality_flags, has_edits);
rd->compute_list_end();
rd->submit();
rd->sync();
// Readback mesh only (no density readback!)
bool mesh_ok = dispatched && mesh_pipeline_.readback_mesh(*req.mesh_output);
(void)mesh_ok;
req.result.set_value(true);
} else {
// Fallback: density-only (CPU mesh path reads it back)
compute_.fill_grid(*req.grid, *req.density);
req.result.set_value(true);
}
}
}
};
#endif // DC_GPU_THREAD_H