#include "dc_compute.h"
#include <cstdlib>
#include "dc_gpu_thread.h" // for GpuFillRequest
#include "dc_gpu_params.h" // shared GpuParams struct
#include "dc_generator_mesh_sdf.h" // for BVH flattening
#include <godot_cpp/classes/rd_shader_source.hpp>
#include <godot_cpp/classes/rd_shader_spirv.hpp>
#include <godot_cpp/classes/rd_uniform.hpp>
#include <godot_cpp/classes/rd_texture_format.hpp>
#include <godot_cpp/classes/rd_texture_view.hpp>
#include <godot_cpp/classes/rd_sampler_state.hpp>
#include <godot_cpp/variant/utility_functions.hpp>
#include <cstring>
#include <cmath>
#include <string>
// Per-chunk GPU edit + convex-plane caps. A coarse-LOD chunk's AABB overlaps a huge
// area, so its culled edit list can be the WHOLE map — START.MAP alone stamps 1193
// brushes. The old caps (512 edits / 8192 planes) silently dropped the excess, so
// distant/coarse chunks were missing geometry that only "filled in" as finer chunks
// (fewer culled edits each) took over. Raised to cover START with headroom. Cost is
// bounded: dense chunks bake a settled brick (BRICK_EDIT_THRESHOLD=16), so the O(edits)
// work is a one-time bake on the few coarse chunks, not steady-state meshing.
static constexpr int MAX_GPU_EDITS = 1536; // per-chunk culled edit cap (was 512)
static constexpr int MAX_CONVEX_PLANES = 16384; // convex half-space plane cap (was 8192)
#include "dc_shader_source.h" // HEADER/BODY/MAIN GLSL + the two assemblers (godot-free, shared with the GPU oracle)
// GpuParams struct now in dc_gpu_params.h (shared with dc_worker.cpp)
// -------------------------------------------------------------------------
// DCCompute implementation
// -------------------------------------------------------------------------
DCCompute::DCCompute() = default;
DCCompute::~DCCompute() {
cleanup();
}
bool DCCompute::initialize() {
// Create a local rendering device on THIS thread (thread affinity in Godot 4.6).
// All subsequent RD operations must happen on this same thread.
RenderingServer* rs = RenderingServer::get_singleton();
if (!rs) {
UtilityFunctions::print("DCCompute: RenderingServer not available");
return false;
}
rd_ = rs->create_local_rendering_device();
if (!rd_) {
UtilityFunctions::print("DCCompute: Failed to create local RenderingDevice (no Vulkan?)");
return false;
}
// Compile default shader (HEADER + BODY + MAIN)
std::string default_shader = _build_default_shader();
Ref<RDShaderSource> src;
src.instantiate();
src->set_stage_source(RenderingDevice::SHADER_STAGE_COMPUTE, String(default_shader.c_str()));
Ref<RDShaderSPIRV> spirv = rd_->shader_compile_spirv_from_source(src);
if (spirv.is_null()) {
UtilityFunctions::print("DCCompute: SPIR-V compilation returned null");
memdelete(rd_);
rd_ = nullptr;
return false;
}
// Check for compile errors
String err = spirv->get_stage_compile_error(RenderingDevice::SHADER_STAGE_COMPUTE);
if (!err.is_empty()) {
UtilityFunctions::print(String("DCCompute: Shader compile error: ") + err);
memdelete(rd_);
rd_ = nullptr;
return false;
}
shader_ = rd_->shader_create_from_spirv(spirv);
if (!shader_.is_valid()) {
UtilityFunctions::print("DCCompute: Failed to create shader from SPIR-V");
memdelete(rd_);
rd_ = nullptr;
return false;
}
pipeline_ = rd_->compute_pipeline_create(shader_);
if (!pipeline_.is_valid()) {
UtilityFunctions::print("DCCompute: Failed to create compute pipeline");
rd_->free_rid(shader_);
memdelete(rd_);
rd_ = nullptr;
return false;
}
// Validate that RD data operations work from this thread.
// Stock Godot 4.6 blocks buffer_update/free_rid from non-render threads
// even on local RenderingDevices. If this test fails, fall back to CPU.
{
PackedByteArray test_data;
test_data.resize(16);
RID test_buf = rd_->storage_buffer_create(16, test_data);
if (!test_buf.is_valid()) {
UtilityFunctions::print("DCCompute: RD data ops blocked (stock Godot?) — falling back to CPU");
rd_->free_rid(pipeline_);
pipeline_ = RID();
rd_->free_rid(shader_);
shader_ = RID();
memdelete(rd_);
rd_ = nullptr;
return false;
}
Error err = rd_->buffer_update(test_buf, 0, 16, test_data);
rd_->free_rid(test_buf);
if (err != OK) {
UtilityFunctions::print("DCCompute: buffer_update blocked (stock Godot?) — falling back to CPU");
rd_->free_rid(pipeline_);
pipeline_ = RID();
rd_->free_rid(shader_);
shader_ = RID();
memdelete(rd_);
rd_ = nullptr;
return false;
}
}
available_ = true;
UtilityFunctions::print("DCCompute: GPU compute initialized on dedicated thread");
return true;
}
bool DCCompute::set_custom_shader(const char* glsl) {
if (!rd_ || !available_) return false;
// Custom GLSL provides evaluate_density() + helpers but NOT the compute
// shader boilerplate. Wrap it in the HEADER + MAIN template.
// When glsl is null, rebuild the default shader.
// Must be set BEFORE _rebuild_uniform_set so the descriptors match what the
// header just declared.
sdf_uses_noise_ = _body_uses_noise_volumes(glsl);
std::string full_shader = glsl ? _build_custom_shader(glsl) : _build_default_shader();
Ref<RDShaderSource> src;
src.instantiate();
src->set_stage_source(RenderingDevice::SHADER_STAGE_COMPUTE, String(full_shader.c_str()));
Ref<RDShaderSPIRV> spirv = rd_->shader_compile_spirv_from_source(src);
if (spirv.is_null()) {
fprintf(stderr, "DCCompute: Custom shader SPIR-V compilation returned null\n");
return false;
}
String err = spirv->get_stage_compile_error(RenderingDevice::SHADER_STAGE_COMPUTE);
if (!err.is_empty()) {
fprintf(stderr, "DCCompute: Custom shader compile error: %s\n", err.utf8().get_data());
return false;
}
// Free old pipeline and shader. Freeing a shader IMPLICITLY invalidates
// every uniform set built against it, so drop our handle without freeing
// it -- _rebuild_uniform_set would otherwise free it a second time, which
// is the "DOUBLE FREE (was uniform_set)" the log reports on every recompile.
if (pipeline_.is_valid()) rd_->free_rid(pipeline_);
if (shader_.is_valid()) rd_->free_rid(shader_);
uniform_set_ = RID();
shader_ = rd_->shader_create_from_spirv(spirv);
if (!shader_.is_valid()) {
fprintf(stderr, "DCCompute: Failed to create custom shader\n");
return false;
}
pipeline_ = rd_->compute_pipeline_create(shader_);
if (!pipeline_.is_valid()) {
fprintf(stderr, "DCCompute: Failed to create custom pipeline\n");
rd_->free_rid(shader_);
shader_ = RID();
return false;
}
// Force uniform set rebuild
uniform_set_dirty_ = true;
fprintf(stderr, "DCCompute: Custom shader compiled successfully\n");
return true;
}
void DCCompute::cleanup() {
if (!rd_) return;
_destroy_buffers();
// The custom data outlives stride changes by design (see _destroy_buffers),
// so this is the only place it is released.
if (custom_data_buf_.is_valid()) { rd_->free_rid(custom_data_buf_); custom_data_buf_ = RID(); custom_data_n_ = 0; }
_free_noise();
if (noise_sampler_linear_.is_valid()) { rd_->free_rid(noise_sampler_linear_); noise_sampler_linear_ = RID(); }
if (noise_sampler_nearest_.is_valid()) { rd_->free_rid(noise_sampler_nearest_); noise_sampler_nearest_ = RID(); }
// The atlas pair. Textures are RIDs like any buffer and cleanup() did not
// free them — the first real run reported
// WARNING: 1 RID of type "Texture" was leaked.
// which is also how we learned the atlas path executes at all, since
// dc-terrain created no textures before it.
if (atlas_tex_.is_valid()) { rd_->free_rid(atlas_tex_); atlas_tex_ = RID(); }
if (atlas_staging_.is_valid()) { rd_->free_rid(atlas_staging_); atlas_staging_ = RID(); }
atlas_w_ = 0; atlas_h_ = 0;
if (pipeline_.is_valid()) {
rd_->free_rid(pipeline_);
pipeline_ = RID();
}
if (shader_.is_valid()) {
rd_->free_rid(shader_);
shader_ = RID();
}
memdelete(rd_);
rd_ = nullptr;
available_ = false;
}
void DCCompute::_create_buffers(int stride) {
if (current_stride_ == stride) return;
_destroy_buffers();
int total = stride * stride * stride;
int buf_size = total * (int)sizeof(float);
// Create storage buffers for density and normals
PackedByteArray zero_data;
zero_data.resize(buf_size);
memset(zero_data.ptrw(), 0, buf_size);
density_buf_ = rd_->storage_buffer_create(buf_size, zero_data);
nx_buf_ = rd_->storage_buffer_create(buf_size, zero_data);
ny_buf_ = rd_->storage_buffer_create(buf_size, zero_data);
nz_buf_ = rd_->storage_buffer_create(buf_size, zero_data);
// Params UBO — sized to GpuParams struct (round up to 16-byte alignment for std140)
int params_size = (int)sizeof(GpuParams);
// std140 requires UBO size to be a multiple of 16
params_size = ((params_size + 15) / 16) * 16;
PackedByteArray params_zero;
params_zero.resize(params_size);
memset(params_zero.ptrw(), 0, params_size);
params_buf_ = rd_->uniform_buffer_create(params_size, params_zero);
// Voronoi buffer — allocate for max possible cells (20*20 = 400 cells max)
int max_cells = 400;
int voronoi_size = (int)sizeof(int32_t) + max_cells * 3 * (int)sizeof(float);
voronoi_size = ((voronoi_size + 15) / 16) * 16;
PackedByteArray voronoi_zero;
voronoi_zero.resize(voronoi_size);
memset(voronoi_zero.ptrw(), 0, voronoi_size);
voronoi_buf_ = rd_->storage_buffer_create(voronoi_size, voronoi_zero);
// Permutation buffer: 6 * 512 ints
int perm_size = 6 * 512 * (int)sizeof(int32_t);
PackedByteArray perm_zero;
perm_zero.resize(perm_size);
memset(perm_zero.ptrw(), 0, perm_size);
perm_buf_ = rd_->storage_buffer_create(perm_size, perm_zero);
// Edit buffer: 16-byte header (count + padding) + MAX_GPU_EDITS × 48 bytes
int edit_buf_size = 16 + MAX_GPU_EDITS * 48;
PackedByteArray edit_zero;
edit_zero.resize(edit_buf_size);
memset(edit_zero.ptrw(), 0, edit_buf_size); // edit_count = 0
edit_buf_ = rd_->storage_buffer_create(edit_buf_size, edit_zero);
// Settled-edit SDF delta brick: same size/stride as the density grid
// (stride^3 floats), zero-initialized so apply_brick_delta is an exact no-op
// until a bake pass fills it. Bound at binding 11 (density_fill) / 10
// (edge_intersect). Alloc-once per stride like the other buffers.
brick_buf_ = rd_->storage_buffer_create(buf_size, zero_data);
// Baked custom data (binding 13): a one-float placeholder until
// set_custom_data() uploads a real block. A descriptor in the set MUST
// point at a live buffer even when the shader never reads it, so this
// cannot simply be left invalid.
if (!custom_data_buf_.is_valid()) {
PackedByteArray hz; hz.resize(4); memset(hz.ptrw(), 0, 4);
custom_data_buf_ = rd_->storage_buffer_create(4, hz);
}
_create_noise_placeholders();
// Mesh BVH buffers: start with small empties (resized on first mesh upload)
// Binding 8: BVH nodes (16-byte header + nodes)
PackedByteArray bvh_zero;
bvh_zero.resize(16);
memset(bvh_zero.ptrw(), 0, 16); // mesh_node_count = 0
bvh_node_buf_ = rd_->storage_buffer_create(16, bvh_zero);
// Binding 9: triangles (min 128 bytes for one triangle slot)
PackedByteArray tri_zero;
tri_zero.resize(128);
memset(tri_zero.ptrw(), 0, 128);
bvh_tri_buf_ = rd_->storage_buffer_create(128, tri_zero);
// Binding 10: triangle indices (min 16 bytes)
PackedByteArray idx_zero;
idx_zero.resize(16);
memset(idx_zero.ptrw(), 0, 16);
bvh_idx_buf_ = rd_->storage_buffer_create(16, idx_zero);
// Binding 12: convex-brush planes. FIXED max size, updated in place (like
// edit_buf_) so the RID never changes — the mesh-pipeline uniform set binds
// this buffer too, so recreating it would leave that set pointing at a freed
// RID. MAX_CONVEX_PLANES × 16 B; must match the clamp in upload_edits.
int cvx_bytes = MAX_CONVEX_PLANES * 16; // MAX_CONVEX_PLANES × vec4
PackedByteArray cvx_zero;
cvx_zero.resize(cvx_bytes);
memset(cvx_zero.ptrw(), 0, cvx_bytes);
convex_plane_buf_ = rd_->storage_buffer_create(cvx_bytes, cvx_zero);
// Parallel per-face projection index (one uint per plane; Stage-2 texturing). Same
// fixed capacity + in-place update as convex_plane_buf_ so the RID is stable.
int fp_bytes = MAX_CONVEX_PLANES * 4;
PackedByteArray fp_zero;
fp_zero.resize(fp_bytes);
memset(fp_zero.ptrw(), 0, fp_bytes);
convex_faceproj_buf_ = rd_->storage_buffer_create(fp_bytes, fp_zero);
current_stride_ = stride;
uniform_set_dirty_ = true; // new buffers need new uniform set
}
void DCCompute::_destroy_buffers() {
if (!rd_) return;
if (uniform_set_.is_valid()) {
rd_->free_rid(uniform_set_);
uniform_set_ = RID();
}
// NOT the custom data. _destroy_buffers runs whenever the grid STRIDE
// changes, and the custom data has nothing to do with the grid — it is the
// world, not the chunk. Freeing it here recreated it as the 4-byte
// placeholder in _create_buffers, while DCGpuThread's current_custom_data_ still
// held the same pointer and therefore never re-uploaded. The shader then
// read past a 4-float buffer, heightmap_gn returned a constant, and the
// terrain came out perfectly flat. It is released in cleanup() instead.
if (density_buf_.is_valid()) { rd_->free_rid(density_buf_); density_buf_ = RID(); }
if (nx_buf_.is_valid()) { rd_->free_rid(nx_buf_); nx_buf_ = RID(); }
if (ny_buf_.is_valid()) { rd_->free_rid(ny_buf_); ny_buf_ = RID(); }
if (nz_buf_.is_valid()) { rd_->free_rid(nz_buf_); nz_buf_ = RID(); }
if (params_buf_.is_valid()) { rd_->free_rid(params_buf_); params_buf_ = RID(); }
if (voronoi_buf_.is_valid()) { rd_->free_rid(voronoi_buf_); voronoi_buf_ = RID(); }
if (perm_buf_.is_valid()) { rd_->free_rid(perm_buf_); perm_buf_ = RID(); }
if (edit_buf_.is_valid()) { rd_->free_rid(edit_buf_); edit_buf_ = RID(); }
if (brick_buf_.is_valid()) { rd_->free_rid(brick_buf_); brick_buf_ = RID(); }
// Buffers are going away, so whatever they held is no longer resident. Without
// this the pointer compare above would skip re-uploading into freed buffers.
bvh_uploaded_for_ = nullptr;
if (bvh_node_buf_.is_valid()) { rd_->free_rid(bvh_node_buf_); bvh_node_buf_ = RID(); }
if (bvh_tri_buf_.is_valid()) { rd_->free_rid(bvh_tri_buf_); bvh_tri_buf_ = RID(); }
if (bvh_idx_buf_.is_valid()) { rd_->free_rid(bvh_idx_buf_); bvh_idx_buf_ = RID(); }
if (convex_plane_buf_.is_valid()) { rd_->free_rid(convex_plane_buf_); convex_plane_buf_ = RID(); }
if (convex_faceproj_buf_.is_valid()) { rd_->free_rid(convex_faceproj_buf_); convex_faceproj_buf_ = RID(); }
current_stride_ = 0;
uniform_set_dirty_ = true;
}
// Upload a baked custom data block. Reallocates, so the uniform set is
// invalidated -- a storage buffer cannot be resized in place and the old RID
// stays referenced by the set until it is rebuilt.
// A sampler descriptor, like a buffer descriptor, MUST point at a live object
// even when the injected SDF body never reads it. Eight 1^3 volumes cost
// nothing and keep every uniform set valid for generators that use no noise.
void DCCompute::_create_noise_placeholders() {
if (!noise_sampler_linear_.is_valid()) {
for (int pass = 0; pass < 2; pass++) {
Ref<RDSamplerState> ss; ss.instantiate();
RenderingDevice::SamplerFilter f = pass == 0
? RenderingDevice::SAMPLER_FILTER_LINEAR
: RenderingDevice::SAMPLER_FILTER_NEAREST;
ss->set_min_filter(f);
ss->set_mag_filter(f);
ss->set_repeat_u(RenderingDevice::SAMPLER_REPEAT_MODE_REPEAT);
ss->set_repeat_v(RenderingDevice::SAMPLER_REPEAT_MODE_REPEAT);
ss->set_repeat_w(RenderingDevice::SAMPLER_REPEAT_MODE_REPEAT);
RID r = rd_->sampler_create(ss);
if (pass == 0) noise_sampler_linear_ = r; else noise_sampler_nearest_ = r;
}
}
if (noise_tex_[0].is_valid()) return;
Ref<RDTextureFormat> fmt; fmt.instantiate();
fmt->set_format(RenderingDevice::DATA_FORMAT_R16G16B16A16_SFLOAT);
fmt->set_width(1); fmt->set_height(1); fmt->set_depth(1);
fmt->set_array_layers(1); fmt->set_mipmaps(1);
fmt->set_texture_type(RenderingDevice::TEXTURE_TYPE_3D);
fmt->set_usage_bits(RenderingDevice::TEXTURE_USAGE_SAMPLING_BIT
| RenderingDevice::TEXTURE_USAGE_CAN_UPDATE_BIT);
Ref<RDTextureView> view; view.instantiate();
PackedByteArray zero; zero.resize(8); memset(zero.ptrw(), 0, 8); // 1 RGBA half texel
TypedArray<PackedByteArray> data; data.push_back(zero);
for (int i = 0; i < 8; i++) noise_tex_[i] = rd_->texture_create(fmt, view, data);
noise_dim_ = 1;
}
void DCCompute::_free_noise() {
for (int i = 0; i < 8; i++) {
if (noise_tex_[i].is_valid()) { rd_->free_rid(noise_tex_[i]); noise_tex_[i] = RID(); }
}
noise_dim_ = 0;
}
void DCCompute::set_noise_volumes(const void* const* volumes, int dim) {
if (rd_ == nullptr) return;
_free_noise();
if (volumes == nullptr || dim <= 0) { _create_noise_placeholders(); return; }
Ref<RDTextureFormat> fmt; fmt.instantiate();
fmt->set_format(RenderingDevice::DATA_FORMAT_R16G16B16A16_SFLOAT);
fmt->set_width(dim); fmt->set_height(dim); fmt->set_depth(dim);
fmt->set_array_layers(1); fmt->set_mipmaps(1);
fmt->set_texture_type(RenderingDevice::TEXTURE_TYPE_3D);
fmt->set_usage_bits(RenderingDevice::TEXTURE_USAGE_SAMPLING_BIT
| RenderingDevice::TEXTURE_USAGE_CAN_UPDATE_BIT);
Ref<RDTextureView> view; view.instantiate();
const int64_t n_bytes = int64_t(dim) * dim * dim * 4 * 2; // RGBA half
for (int i = 0; i < 8; i++) {
PackedByteArray b; b.resize(n_bytes);
memcpy(b.ptrw(), volumes[i], size_t(n_bytes));
TypedArray<PackedByteArray> data; data.push_back(b);
noise_tex_[i] = rd_->texture_create(fmt, view, data);
}
noise_dim_ = dim;
}
void DCCompute::set_custom_data(const float* data, int n) {
if (!rd_) return;
if (custom_data_buf_.is_valid()) { rd_->free_rid(custom_data_buf_); custom_data_buf_ = RID(); }
if (data == nullptr || n <= 0) {
PackedByteArray hz; hz.resize(4); memset(hz.ptrw(), 0, 4);
custom_data_buf_ = rd_->storage_buffer_create(4, hz);
custom_data_n_ = 0;
} else {
// n IS AN ELEMENT COUNT (DCGenerator::gpu_custom_data_count). Squaring
// it here is correct only while the data is a plain square, and
// silently truncates a mip pyramid to its base level.
size_t bytes = (size_t)n * sizeof(float);
PackedByteArray pb; pb.resize((int64_t)bytes);
memcpy(pb.ptrw(), data, bytes);
custom_data_buf_ = rd_->storage_buffer_create((uint32_t)bytes, pb);
custom_data_n_ = n;
float lo = data[0], hi = data[0];
for (size_t i = 1; i < (size_t)n; i++) {
if (data[i] < lo) lo = data[i];
if (data[i] > hi) hi = data[i];
}
fprintf(stderr, "DCCompute: custom data uploaded %d floats (%zu bytes), range %.0f..%.0f\n",
n, bytes, (double)lo, (double)hi);
}
// ⛔ THE ATLAS IS NOT READ BY ANY SHADER, so uploading it is pure cost.
// HBLK_USE_ATLAS appears in comments and is DEFINED NOWHERE, so nothing
// samples binding 14; the atlas commits that would have read it were
// reverted (dc-terrain 029cca8, proc-nodes fb23181). It stayed wired so the
// SSBO and the texture could be diffed, and the diff never ran.
//
// At 841 tiles the buffer is 224 MB, so calling this DOUBLES a load that is
// already the largest single upload in the engine, to build a texture no
// one samples. Off until something reads it; one line to re-enable, and the
// per-tile texture_copy blits it exists for are still proven in
// gate_texture_atlas.gd.
// A/B SWITCH, because two global changes landed together and guessing which
// one regressed a map that does not even use the tile grid has already cost
// several runs. DC_ATLAS=1 restores the pre-change behaviour.
static const bool kUploadAtlas = (getenv("DC_ATLAS") != nullptr);
if (kUploadAtlas) _upload_atlas(data, n);
uniform_set_dirty_ = true;
}
// The SAME floats, as a 2-D texture. Linear index i -> (i % W, i / W).
//
// A LINEAR MAPPING, NOT A TILE GRID, and deliberately so for this step: it is a
// pure TRANSPORT swap, so custom_data[i] and the atlas hold identical values at
// identical indices and the two can be proved equivalent by the existing
// gate. Laying the mip pyramid out as 2-D tile rectangles is a LAYOUT change
// and belongs in its own step, where it can fail on its own.
void DCCompute::_upload_atlas(const float* data, int n) {
if (!rd_) return;
if (atlas_tex_.is_valid()) { rd_->free_rid(atlas_tex_); atlas_tex_ = RID(); }
if (atlas_staging_.is_valid()) { rd_->free_rid(atlas_staging_); atlas_staging_ = RID(); }
const int W = 4096;
int count = (data == nullptr || n <= 0) ? 1 : n;
int H = (count + W - 1) / W;
atlas_w_ = W; atlas_h_ = H;
Ref<RDTextureFormat> fmt; fmt.instantiate();
fmt->set_format(RenderingDevice::DATA_FORMAT_R32_SFLOAT);
fmt->set_width((uint32_t)W); fmt->set_height((uint32_t)H); fmt->set_depth(1);
fmt->set_array_layers(1); fmt->set_mipmaps(1);
fmt->set_texture_type(RenderingDevice::TEXTURE_TYPE_2D);
// ⚠ CAN_UPDATE_BIT is required by texture_update and its ABSENCE IS NOT A
// CREATION ERROR — texture_create succeeds and the update fails much later.
fmt->set_usage_bits(RenderingDevice::TEXTURE_USAGE_STORAGE_BIT
| RenderingDevice::TEXTURE_USAGE_CAN_UPDATE_BIT
| RenderingDevice::TEXTURE_USAGE_CAN_COPY_TO_BIT
| RenderingDevice::TEXTURE_USAGE_CAN_COPY_FROM_BIT);
Ref<RDTextureView> view; view.instantiate();
atlas_tex_ = rd_->texture_create(fmt, view, TypedArray<PackedByteArray>());
if (!atlas_tex_.is_valid()) {
fprintf(stderr, "DCCompute: atlas texture_create FAILED (%dx%d)\n", W, H);
return;
}
// texture_update is WHOLE-LAYER, so the padded full-size image is written
// once here. Per-tile sub-region blits are the point of the texture and
// come with the streaming window; this step only proves the transport.
PackedByteArray pb;
pb.resize((int64_t)W * H * 4);
memset(pb.ptrw(), 0, (size_t)W * H * 4);
if (data != nullptr && n > 0) memcpy(pb.ptrw(), data, (size_t)n * sizeof(float));
if (rd_->texture_update(atlas_tex_, 0, pb) != 0) {
fprintf(stderr, "DCCompute: atlas texture_update FAILED\n");
return;
}
fprintf(stderr, "DCCompute: atlas %dx%d holds %d floats (%.1f MB)\n",
W, H, count, (double)W * H * 4 / 1048576.0);
}
void DCCompute::_rebuild_uniform_set() {
// Skip rebuild if buffers haven't changed (optimization 5)
if (!uniform_set_dirty_ && uniform_set_.is_valid()) return;
if (uniform_set_.is_valid()) {
rd_->free_rid(uniform_set_);
uniform_set_ = RID();
}
// binding 0: density SSBO
Ref<RDUniform> u0;
u0.instantiate();
u0->set_uniform_type(RenderingDevice::UNIFORM_TYPE_STORAGE_BUFFER);
u0->set_binding(0);
u0->add_id(density_buf_);
// binding 1: normal_x SSBO
Ref<RDUniform> u1;
u1.instantiate();
u1->set_uniform_type(RenderingDevice::UNIFORM_TYPE_STORAGE_BUFFER);
u1->set_binding(1);
u1->add_id(nx_buf_);
// binding 2: normal_y SSBO
Ref<RDUniform> u2;
u2.instantiate();
u2->set_uniform_type(RenderingDevice::UNIFORM_TYPE_STORAGE_BUFFER);
u2->set_binding(2);
u2->add_id(ny_buf_);
// binding 3: normal_z SSBO
Ref<RDUniform> u3;
u3.instantiate();
u3->set_uniform_type(RenderingDevice::UNIFORM_TYPE_STORAGE_BUFFER);
u3->set_binding(3);
u3->add_id(nz_buf_);
// binding 4: params UBO
Ref<RDUniform> u4;
u4.instantiate();
u4->set_uniform_type(RenderingDevice::UNIFORM_TYPE_UNIFORM_BUFFER);
u4->set_binding(4);
u4->add_id(params_buf_);
// binding 5: voronoi SSBO
Ref<RDUniform> u5;
u5.instantiate();
u5->set_uniform_type(RenderingDevice::UNIFORM_TYPE_STORAGE_BUFFER);
u5->set_binding(5);
u5->add_id(voronoi_buf_);
// binding 6: perm SSBO
Ref<RDUniform> u6;
u6.instantiate();
u6->set_uniform_type(RenderingDevice::UNIFORM_TYPE_STORAGE_BUFFER);
u6->set_binding(6);
u6->add_id(perm_buf_);
// binding 7: edit SSBO
Ref<RDUniform> u7;
u7.instantiate();
u7->set_uniform_type(RenderingDevice::UNIFORM_TYPE_STORAGE_BUFFER);
u7->set_binding(7);
u7->add_id(edit_buf_);
// binding 8: mesh BVH nodes SSBO
Ref<RDUniform> u8;
u8.instantiate();
u8->set_uniform_type(RenderingDevice::UNIFORM_TYPE_STORAGE_BUFFER);
u8->set_binding(8);
u8->add_id(bvh_node_buf_);
// binding 9: mesh triangles SSBO
Ref<RDUniform> u9;
u9.instantiate();
u9->set_uniform_type(RenderingDevice::UNIFORM_TYPE_STORAGE_BUFFER);
u9->set_binding(9);
u9->add_id(bvh_tri_buf_);
// binding 10: mesh triangle indices SSBO
Ref<RDUniform> u10;
u10.instantiate();
u10->set_uniform_type(RenderingDevice::UNIFORM_TYPE_STORAGE_BUFFER);
u10->set_binding(10);
u10->add_id(bvh_idx_buf_);
// binding 11: settled-edit brick SSBO
Ref<RDUniform> u11;
u11.instantiate();
u11->set_uniform_type(RenderingDevice::UNIFORM_TYPE_STORAGE_BUFFER);
u11->set_binding(11);
u11->add_id(brick_buf_);
// binding 13: baked custom data SSBO
Ref<RDUniform> u13;
u13.instantiate();
u13->set_uniform_type(RenderingDevice::UNIFORM_TYPE_STORAGE_BUFFER);
u13->set_binding(13);
u13->add_id(custom_data_buf_);
// binding 14: the SAME data as a texture. A descriptor must point at a live
// resource even when the shader never reads it (see the note on binding 13's
// buffer), so this is created unconditionally.
if (!atlas_tex_.is_valid()) _upload_atlas(nullptr, 0);
Ref<RDUniform> u14;
u14.instantiate();
u14->set_uniform_type(RenderingDevice::UNIFORM_TYPE_IMAGE);
u14->set_binding(14);
u14->add_id(atlas_tex_);
// binding 12: convex-brush planes SSBO
Ref<RDUniform> u12;
u12.instantiate();
u12->set_uniform_type(RenderingDevice::UNIFORM_TYPE_STORAGE_BUFFER);
u12->set_binding(12);
u12->add_id(convex_plane_buf_);
// bindings 15..22: the eight noise volumes, present only when the generator
// body samples them (the header declares them under the same condition).
TypedArray<RDUniform> noise_uniforms;
if (sdf_uses_noise_) {
for (int i = 0; i < 8; i++) {
Ref<RDUniform> un;
un.instantiate();
un->set_uniform_type(RenderingDevice::UNIFORM_TYPE_SAMPLER_WITH_TEXTURE);
un->set_binding(15 + i);
un->add_id(i < 4 ? noise_sampler_linear_ : noise_sampler_nearest_);
un->add_id(noise_tex_[i]);
noise_uniforms.push_back(un);
}
}
TypedArray<RDUniform> uniforms;
uniforms.push_back(u0);
uniforms.push_back(u1);
uniforms.push_back(u2);
uniforms.push_back(u3);
uniforms.push_back(u4);
uniforms.push_back(u5);
uniforms.push_back(u6);
uniforms.push_back(u7);
uniforms.push_back(u8);
uniforms.push_back(u9);
uniforms.push_back(u10);
uniforms.push_back(u11);
uniforms.push_back(u12);
uniforms.push_back(u13);
uniforms.push_back(u14);
for (int i = 0; i < noise_uniforms.size(); i++) uniforms.push_back(noise_uniforms[i]);
uniform_set_ = rd_->uniform_set_create(uniforms, shader_, 0);
if (!uniform_set_.is_valid()) {
// A set that fails to create means every dispatch after it reads
// nothing. Silent until now, and indistinguishable from a wrong field.
fprintf(stderr, "DCCompute: uniform_set_create FAILED (%d uniforms) — "
"density dispatches will not see their buffers\n",
(int)uniforms.size());
}
uniform_set_dirty_ = false;
}
void DCCompute::_upload_params(const DensityGrid& grid, const DCDensity& density) {
const DensityConfig& c = density.config();
GpuParams params;
memset(¶ms, 0, sizeof(params));
params.grid_stride = grid.stride;
params.voxel_scale = grid.voxel_scale;
params.origin_x = grid.origin[0];
params.origin_y = grid.origin[1];
params.origin_z = grid.origin[2];
params.primary_frequency = c.primary_frequency;
params.primary_strength = c.primary_strength;
params.tunnel_frequency = c.tunnel_frequency;
params.tunnel_strength = c.tunnel_strength;
params.tunnel_radius = c.tunnel_radius;
params.ridge_frequency = c.ridge_frequency;
params.ridge_strength = c.ridge_strength;
params.warp_frequency = c.warp_frequency;
params.warp_strength = c.warp_strength;
params.detail_frequency = c.detail_frequency;
params.detail_strength = c.detail_strength;
params.ground_bias = c.ground_bias;
params.solid_floor_depth = c.solid_floor_depth;
params.sky_height = c.sky_height;
params.flat_center_radius= c.flat_center_radius;
params.floor_strength = c.floor_strength;
params.floor_height_min = c.floor_height_min;
params.floor_height_max = c.floor_height_max;
params.room_height = c.room_height;
params.terrain_size = c.terrain_size;
params.primary_octaves = density.get_primary_octaves();
params.tunnel_octaves = density.get_tunnel_octaves();
params.ridge_octaves = density.get_ridge_octaves();
params.warp_octaves = density.get_warp_octaves();
params.detail_octaves = density.get_detail_octaves();
params.compute_normals = 0; // normals computed on CPU after density smoothing
params.seed = c.seed;
params.voronoi_cell_count = c.voronoi_cell_count;
params.use_noise = c.use_noise ? 1 : 0;
params.spherical = c.spherical ? 1 : 0;
params.planet_radius = c.planet_radius;
params.planet_center_x = c.planet_center[0];
params.planet_center_y = c.planet_center[1];
params.planet_center_z = c.planet_center[2];
params.island_radius = c.island_radius;
// Upload params to UBO
{
PackedByteArray pack;
int params_size = ((int)sizeof(GpuParams) + 15) / 16 * 16;
pack.resize(params_size);
memset(pack.ptrw(), 0, params_size);
memcpy(pack.ptrw(), ¶ms, sizeof(GpuParams));
rd_->buffer_update(params_buf_, 0, params_size, pack);
}
// Upload Voronoi data
{
const auto& cells = density.get_voronoi_cells();
int count = (int)cells.size();
int data_size = (int)sizeof(int32_t) + count * 3 * (int)sizeof(float);
int padded_size = ((data_size + 15) / 16) * 16;
int max_cells = 400;
if (count > max_cells) count = max_cells;
PackedByteArray pack;
pack.resize(padded_size);
memset(pack.ptrw(), 0, padded_size);
uint8_t* ptr = pack.ptrw();
memcpy(ptr, &count, sizeof(int32_t));
ptr += sizeof(int32_t);
for (int i = 0; i < count; i++) {
float vals[3] = { cells[i].x, cells[i].z, cells[i].floor_height };
memcpy(ptr + i * 3 * sizeof(float), vals, 3 * sizeof(float));
}
rd_->buffer_update(voronoi_buf_, 0, padded_size, pack);
}
// Upload permutation tables
{
std::vector<int> tables = density.get_perm_tables();
int perm_size = (int)(tables.size() * sizeof(int));
PackedByteArray pack;
pack.resize(perm_size);
memcpy(pack.ptrw(), tables.data(), perm_size);
rd_->buffer_update(perm_buf_, 0, perm_size, pack);
}
}
void DCCompute::_dispatch_and_readback(DensityGrid& grid) {
int stride = grid.stride;
int groups = (stride + 7) / 8;
int64_t compute_list = rd_->compute_list_begin();
rd_->compute_list_bind_compute_pipeline(compute_list, pipeline_);
rd_->compute_list_bind_uniform_set(compute_list, uniform_set_, 0);
rd_->compute_list_dispatch(compute_list, groups, groups, groups);
rd_->compute_list_end();
rd_->submit();
rd_->sync();
// Read back density only (normals computed on CPU after smoothing)
int total = stride * stride * stride;
int buf_size = total * (int)sizeof(float);
PackedByteArray density_data = rd_->buffer_get_data(density_buf_, 0, buf_size);
memcpy(grid.data.data(), density_data.ptr(), buf_size);
}
void DCCompute::prepare_density(DensityGrid& grid, const DCDensity& density) {
if (!available_ || !rd_) return;
_create_buffers(grid.stride);
_upload_params(grid, density);
_rebuild_uniform_set();
}
bool DCCompute::dispatch_density(int64_t compute_list, int stride) {
if (!available_ || !rd_ || !uniform_set_.is_valid()) return false;
int groups = (stride + 7) / 8;
rd_->compute_list_bind_compute_pipeline(compute_list, pipeline_);
rd_->compute_list_bind_uniform_set(compute_list, uniform_set_, 0);
rd_->compute_list_dispatch(compute_list, groups, groups, groups);
rd_->compute_list_add_barrier(compute_list);
return true;
}
void DCCompute::_upload_flags(int32_t flags) {
if (!rd_ || !params_buf_.is_valid()) return;
// flags is the trailing field of GpuParams (offset = sizeof - 4 = 160).
int off = (int)sizeof(GpuParams) - 4;
PackedByteArray p;
p.resize(4);
memcpy(p.ptrw(), &flags, 4);
rd_->buffer_update(params_buf_, off, 4, p);
}
void DCCompute::bake_brick(int stride) {
if (!available_ || !rd_ || !uniform_set_.is_valid()) return;
_upload_flags(1); // FLAG_BAKE — density main writes brick_delta[] = applied - base
int groups = (stride + 7) / 8;
int64_t cl = rd_->compute_list_begin();
rd_->compute_list_bind_compute_pipeline(cl, pipeline_);
rd_->compute_list_bind_uniform_set(cl, uniform_set_, 0);
rd_->compute_list_dispatch(cl, groups, groups, groups);
rd_->compute_list_end();
rd_->submit();
rd_->sync();
}
void DCCompute::set_brick_active(bool active) {
_upload_flags(active ? 2 : 0); // FLAG_BRICK
}
void DCCompute::fill_grid(DensityGrid& grid, const DCDensity& density) {
if (!available_ || !rd_) return;
int stride = grid.stride;
// 1. Create/resize buffers if needed
_create_buffers(stride);
// 2. Upload params + data
_upload_params(grid, density);
// 3. Rebuild uniform set (only if dirty — optimization 5)
_rebuild_uniform_set();
// 4. Dispatch and read back
_dispatch_and_readback(grid);
}
bool DCCompute::fill_grid_batch(std::vector<GpuFillRequest>& batch) {
if (!available_ || !rd_ || batch.empty()) return false;
// All chunks use the same grid stride, so buffers are shared.
// We dispatch each grid sequentially within one submit+sync cycle.
//
// Batched approach: update params, dispatch for each grid in one
// compute list, then one submit+sync, then read back all.
// NOTE: Since all dispatches write to the SAME density_buf_, we cannot
// truly batch multiple dispatches in one compute list without separate
// output buffers. Instead, we serialize dispatches but amortize the
// uniform set rebuild cost (which is the main win for optimization 5).
int stride = batch[0].grid->stride;
_create_buffers(stride);
_rebuild_uniform_set();
for (auto& req : batch) {
// Upload per-grid params (origin, etc. differ per chunk)
_upload_params(*req.grid, *req.density);
// Dispatch
int groups = (stride + 7) / 8;
int64_t compute_list = rd_->compute_list_begin();
rd_->compute_list_bind_compute_pipeline(compute_list, pipeline_);
rd_->compute_list_bind_uniform_set(compute_list, uniform_set_, 0);
rd_->compute_list_dispatch(compute_list, groups, groups, groups);
rd_->compute_list_end();
rd_->submit();
rd_->sync();
// Read back into this grid
int total = stride * stride * stride;
int buf_size = total * (int)sizeof(float);
PackedByteArray density_data = rd_->buffer_get_data(density_buf_, 0, buf_size);
memcpy(req.grid->data.data(), density_data.ptr(), buf_size);
}
return true;
}
DCCompute::EditUploadStats DCCompute::upload_edits(const std::vector<TerrainEdit>* edits) {
EditUploadStats stats;
if (edits) stats.culled = static_cast<int>(edits->size());
if (!rd_ || !edit_buf_.is_valid()) return stats;
// GPU-side struct layout (must match GLSL GpuEdit: 48 bytes)
struct GpuEditPacked {
uint32_t edit_type;
float pos_x, pos_y, pos_z;
float half_x, half_y, half_z;
float radius;
float strength;
uint32_t _pad0, _pad1, _pad2;
};
static_assert(sizeof(GpuEditPacked) == 48, "GpuEdit must be 48 bytes");
std::vector<GpuEditPacked> gpu_edits;
std::vector<float> convex_plane_data; // flattened vec4(nx,ny,nz,d) for CONVEX edits
std::vector<uint32_t> convex_faceproj_data; // one uint per plane (parallel), Stage-2 texturing
DCGeneratorMeshSDF* mesh_sdf_ptr = nullptr; // first mesh SDF encountered
size_t examined = 0;
if (edits) {
for (const auto& e : *edits) {
examined++;
GpuEditPacked ge;
memset(&ge, 0, sizeof(ge));
ge.pos_x = e.x;
ge.pos_y = e.y;
ge.pos_z = e.z;
ge.strength = e.strength;
// route ②: material index rides the first spare pad word. Binary-safe
// (48-byte layout unchanged); GLSL GpuEdit._pad0 currently ignores it —
// the texturing shader will read it later. See docs/quake-import-texturing.md.
ge._pad0 = e.material_id;
if (e.shape == TerrainEdit::SPHERE) {
if (e.op == TerrainEdit::OP_HARD) {
ge.edit_type = (e.strength < 0.0f) ? 6 : 7; // hard sphere sub/add
} else {
ge.edit_type = (e.strength < 0.0f) ? 0 : 1; // smooth sphere sub/add
}
ge.radius = e.radius;
} else if (e.shape == TerrainEdit::BOX) {
ge.edit_type = (e.strength < 0.0f) ? 2 : 3;
ge.half_x = e.half_ext[0];
ge.half_y = e.half_ext[1];
ge.half_z = e.half_ext[2];
ge.radius = 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]) * 1.05f;
} else if (e.shape == TerrainEdit::MESH && e.mesh_sdf) {
// Mesh SDF: type 4/5, scale in half_x
ge.edit_type = (e.strength < 0.0f) ? 4 : 5;
ge.half_x = e.scale; // scale stored in half_x
ge.radius = e.radius;
// Capture first mesh SDF for BVH upload.
//
// ONE MESH SDF PER CHUNK. There is a single BVH buffer pair, so
// only this one's triangles are uploaded — a SECOND mesh edit
// overlapping the same chunk is still packed as a type 4/5 edit
// and evaluated by the shader against the FIRST one's tree,
// silently producing geometry that belongs to neither.
//
// The scope is narrower than it sounds: upload_edits runs per
// chunk on that chunk's culled list (dc_gpu_thread.h:277-282), so
// meshes in different chunks never interact. It bites only where
// two overlap — e.g. a mesh stamp placed inside the level's own
// footprint once the §5a.2 shell is a mesh subtract.
//
// Reported rather than fixed. Supporting N would mean N BVH buffer
// pairs and an index per edit, which is a real change to the
// pipeline; a wrong-but-quiet result is the thing worth removing
// first, since it is indistinguishable from a meshing bug.
DCGeneratorMeshSDF* this_sdf = dynamic_cast<DCGeneratorMeshSDF*>(e.mesh_sdf.get());
if (!mesh_sdf_ptr) {
mesh_sdf_ptr = this_sdf;
} else if (this_sdf && this_sdf != mesh_sdf_ptr) {
static bool warned = false;
if (!warned) {
warned = true;
UtilityFunctions::printerr(
"[DC] two mesh SDFs overlap one chunk — only the first has a BVH "
"uploaded, so the second evaluates against the wrong tree. "
"Geometry there is wrong. (reported once)");
}
}
} else if (e.shape == TerrainEdit::CONVEX) {
// Convex brush: type 8/9; plane run [offset,count) in convex_planes[]
// (offset/count ride _pad1/_pad2). Clamp total planes to the fixed
// MAX_CONVEX_PLANES buffer (see _create_buffers); skip an edit that
// overflows rather than push a partial one.
uint32_t poff = static_cast<uint32_t>(convex_plane_data.size() / 4);
uint32_t pcnt = static_cast<uint32_t>(e.planes.size());
if (pcnt == 0u || poff + pcnt > (uint32_t)MAX_CONVEX_PLANES) {
// COUNTED, not silent. A plane-budget skip drops one convex
// edit from the MIDDLE of the list, so unlike the cap below
// it is not even a tail truncation — the geometry that edit
// described simply is not there.
if (pcnt != 0u) stats.dropped_planes++;
continue;
}
ge.edit_type = (e.strength < 0.0f) ? 8 : 9;
ge.radius = e.radius;
ge._pad1 = poff;
ge._pad2 = pcnt;
for (const auto& pl : e.planes) {
convex_plane_data.push_back(pl.nx);
convex_plane_data.push_back(pl.ny);
convex_plane_data.push_back(pl.nz);
convex_plane_data.push_back(pl.d);
}
// Parallel projection index per plane (lockstep with convex_plane_data:
// one uint per vec4, so faceproj[poff+pi] matches plane pi). 0 when the
// edit carries no per-face projections (non-imported convex brushes).
for (uint32_t pi = 0; pi < pcnt; pi++) {
convex_faceproj_data.push_back(pi < e.plane_proj.size() ? e.plane_proj[pi] : 0u);
}
}
gpu_edits.push_back(ge);
if (gpu_edits.size() >= (size_t)MAX_GPU_EDITS) {
// THE TAIL IS DROPPED — every edit after this one is never even
// examined, so that, exactly, is the count. Not one, and not
// (total - uploaded): some earlier edits may have been skipped by
// the plane budget above and are already counted there.
stats.dropped_cap = static_cast<int>(edits->size() - examined);
break;
}
}
}
// Upload edit array
uint32_t count = static_cast<uint32_t>(gpu_edits.size());
stats.uploaded = static_cast<int>(count);
int data_size = 16 + static_cast<int>(count) * 48;
PackedByteArray pack;
pack.resize(data_size);
memset(pack.ptrw(), 0, 16);
memcpy(pack.ptrw(), &count, sizeof(uint32_t));
if (count > 0) {
memcpy(pack.ptrw() + 16, gpu_edits.data(), count * 48);
}
rd_->buffer_update(edit_buf_, 0, data_size, pack);
// Upload convex-brush planes in place (fixed buffer → RID stable so the
// mesh-pipeline uniform set stays valid). Empty = harmless (no CONVEX edits).
if (convex_plane_buf_.is_valid() && !convex_plane_data.empty()) {
int cvx_size = static_cast<int>(convex_plane_data.size() * sizeof(float));
PackedByteArray cvx_pack;
cvx_pack.resize(cvx_size);
memcpy(cvx_pack.ptrw(), convex_plane_data.data(), cvx_size);
rd_->buffer_update(convex_plane_buf_, 0, cvx_size, cvx_pack);
}
// Parallel per-face projection indices (Stage-2 texturing). Same in-place update.
if (convex_faceproj_buf_.is_valid() && !convex_faceproj_data.empty()) {
int fp_size = static_cast<int>(convex_faceproj_data.size() * sizeof(uint32_t));
PackedByteArray fp_pack;
fp_pack.resize(fp_size);
memcpy(fp_pack.ptrw(), convex_faceproj_data.data(), fp_size);
rd_->buffer_update(convex_faceproj_buf_, 0, fp_size, fp_pack);
}
// Upload mesh BVH if we have a mesh edit — ONCE PER MESH, not once per chunk.
// See dc_compute.h's bvh_uploaded_for_ for why: this runs per chunk, and
// re-flattening 563544 triangles into ~72 MB and re-creating the buffers for
// every chunk that overlaps the level is what left chunks unmeshed and put
// terrain inside the level's rooms.
if (mesh_sdf_ptr && mesh_sdf_ptr == bvh_uploaded_for_) {
// Already resident. Nothing to do — and importantly, do NOT tear down the
// density uniform set, which the re-upload path below has to.
} else if (mesh_sdf_ptr && bvh_node_buf_.is_valid()) {
std::vector<uint8_t> node_data, tri_data;
std::vector<uint32_t> tri_idx;
int num_nodes = mesh_sdf_ptr->flatten_for_gpu(node_data, tri_data, tri_idx);
if (num_nodes > 0) {
// Free the density uniform set BEFORE reallocating the BVH buffers.
// uniform_set_ references bvh_node/tri/idx (bindings 8-10), so freeing
// those buffers makes Godot RD auto-free uniform_set_ as a dependent —
// leaving our handle a stale RID. _rebuild_uniform_set() then frees it
// again -> "Attempted to free invalid ID" (6382/session on mesh-SDF-edit
// maps like AI Testing Town; absent in the fly-only standalone). Free it
// here while it's still valid, mirroring _destroy_buffers' ordering.
if (uniform_set_.is_valid()) { rd_->free_rid(uniform_set_); uniform_set_ = RID(); }
// Resize BVH node buffer: 16-byte header + node_data
int node_buf_size = 16 + static_cast<int>(node_data.size());
// Recreate buffer if too small
rd_->free_rid(bvh_node_buf_);
PackedByteArray node_pack;
node_pack.resize(node_buf_size);
memset(node_pack.ptrw(), 0, 16);
uint32_t nc = static_cast<uint32_t>(num_nodes);
memcpy(node_pack.ptrw(), &nc, sizeof(uint32_t));
memcpy(node_pack.ptrw() + 16, node_data.data(), node_data.size());
bvh_node_buf_ = rd_->storage_buffer_create(node_buf_size, node_pack);
// Resize triangle buffer
int tri_buf_size = std::max(128, static_cast<int>(tri_data.size()));
rd_->free_rid(bvh_tri_buf_);
PackedByteArray tri_pack;
tri_pack.resize(tri_buf_size);
memset(tri_pack.ptrw(), 0, tri_buf_size);
if (!tri_data.empty()) {
memcpy(tri_pack.ptrw(), tri_data.data(), tri_data.size());
}
bvh_tri_buf_ = rd_->storage_buffer_create(tri_buf_size, tri_pack);
bvh_uploaded_for_ = mesh_sdf_ptr;
// Resize index buffer
int idx_buf_size = std::max(16, static_cast<int>(tri_idx.size() * sizeof(uint32_t)));
rd_->free_rid(bvh_idx_buf_);
PackedByteArray idx_pack;
idx_pack.resize(idx_buf_size);
memset(idx_pack.ptrw(), 0, idx_buf_size);
if (!tri_idx.empty()) {
memcpy(idx_pack.ptrw(), tri_idx.data(), tri_idx.size() * sizeof(uint32_t));
}
bvh_idx_buf_ = rd_->storage_buffer_create(idx_buf_size, idx_pack);
// Buffers changed — need uniform set rebuild
uniform_set_dirty_ = true;
}
}
return stats;
}