OverviewFilesCommitsIssuesDocsReleasesPeers

dc-terrain / src / dc_gpu_thread.h

466 lines · c ref:
#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