#ifndef DC_COLLISION_TRIMESH_H
#define DC_COLLISION_TRIMESH_H
#include <godot_cpp/classes/physics_server3d.hpp>
#include <godot_cpp/classes/world3d.hpp>
#include <godot_cpp/variant/rid.hpp>
#include <godot_cpp/variant/packed_vector3_array.hpp>
#include <godot_cpp/variant/transform3d.hpp>
#include <godot_cpp/variant/vector3.hpp>
#include <godot_cpp/variant/dictionary.hpp>
#include "dc_octree_clipmap.h"
#include <vector>
#include <algorithm>
#include <cmath>
#include <cstdio>
namespace godot {
// ---------------------------------------------------------------------------
// DCCollisionTrimesh — manages queued concave trimesh collision body creation.
// Extracted from DCTerrain::_process() collision building loop.
// DCTerrain instantiates this and delegates collision queue + drain logic.
// ---------------------------------------------------------------------------
class DCCollisionTrimesh {
public:
// Collision request queued per-chunk after mesh upload
struct CollisionRequest {
OctreeClipmapNode* node;
uint32_t node_generation = 0;
PackedVector3Array faces; // triangle face vertices for concave trimesh
};
// Configuration (set by DCTerrain from its properties)
int collision_lod_max = 5; // generate collision for LOD 0..N
float collision_distance = 100.0f; // max distance from camera for collision
// Enqueue a collision request (called from _upload_cell)
void enqueue(CollisionRequest&& req) {
queue_.push_back(std::move(req));
}
// Get current queue size (for stats)
int queue_size() const { return static_cast<int>(queue_.size()); }
// Clear queue (called on rebuild / cleanup)
void clear() { queue_.clear(); deferred_.clear(); }
// Should a given node enqueue collision?
bool should_enqueue(int lod_level, bool has_indices) const {
return lod_level <= collision_lod_max && has_indices;
}
// Process queued collision requests. Builds physics bodies via PhysicsServer3D.
// cam_pos: current camera position (array of 3 floats)
// voxel_size: terrain voxel size
// workers_busy: true if mesh generation workers are still pending
// world: World3D ref for physics space
// debug_logging: emit debug prints
// Returns number of collision bodies built this frame.
int process_queue(const float cam_pos[3], float voxel_size, bool workers_busy,
const Ref<World3D>& world, bool debug_logging) {
int collisions_built = 0;
// Adaptive collision drain — proportional to queue size.
// Higher cap (32) prevents fall-through during LOD transitions
// where 8-24 new chunks appear simultaneously from cascading splits.
int max_collisions_per_frame;
if (workers_busy || queue_.size() <= 1) {
max_collisions_per_frame = 2;
} else {
max_collisions_per_frame = std::max(2, static_cast<int>(queue_.size() / 4));
max_collisions_per_frame = std::min(max_collisions_per_frame, 32);
}
// Distance partition: hold requests beyond collision_distance in a
// separate deferred_ list. This (a) bounds per-frame main-thread
// collision build cost to a near radius — far chunks built for every
// loaded LOD cost 50-340ms/frame → ~4fps (measured); and (b) keeps them
// OUT of queue_size(), because the app's readiness check waits for
// queue_size()==0 before enabling the player — far chunks lingering in
// the build queue would stall "terrain ready" forever. Deferred chunks
// are promoted back when the camera comes within range, so terrain the
// player revisits regains collision (no fall-through — the bug the old
// distance-purge removal avoided). collision_distance<=0 disables this.
if (collision_distance > 0.0f) {
const float r2 = collision_distance * collision_distance;
auto dist_sq_to_cam = [&](const CollisionRequest& r) {
float cs = cell_world_size(r.node->lod_level, voxel_size, 32);
float cx = r.node->position.x * voxel_size + cs * 0.5f;
float cy = r.node->position.y * voxel_size + cs * 0.5f;
float cz = r.node->position.z * voxel_size + cs * 0.5f;
float dx = cx - cam_pos[0], dy = cy - cam_pos[1], dz = cz - cam_pos[2];
return dx*dx + dy*dy + dz*dz;
};
// Demote out-of-range entries from the build queue.
for (int i = static_cast<int>(queue_.size()) - 1; i >= 0; i--) {
if (queue_[i].node && dist_sq_to_cam(queue_[i]) > r2) {
deferred_.push_back(std::move(queue_[i]));
queue_.erase(queue_.begin() + i);
}
}
// Promote in-range deferred entries back; drop stale ones.
for (int i = static_cast<int>(deferred_.size()) - 1; i >= 0; i--) {
auto* dn = deferred_[i].node;
bool stale = !dn || dn->generation != deferred_[i].node_generation ||
dn->state != OctreeClipmapNode::ACTIVE ||
dn->collision_body.is_valid() || !dn->is_leaf();
if (stale) {
deferred_.erase(deferred_.begin() + i);
} else if (dist_sq_to_cam(deferred_[i]) <= r2) {
queue_.push_back(std::move(deferred_[i]));
deferred_.erase(deferred_.begin() + i);
}
}
}
// Purge only truly stale entries — NOT by distance.
// Chunks are enqueued during _upload_cell when the camera may be far away.
// Distance purging permanently loses collision for chunks the player later visits.
for (int i = static_cast<int>(queue_.size()) - 1; i >= 0; i--) {
auto* node = queue_[i].node;
if (!node || node->generation != queue_[i].node_generation ||
node->state != OctreeClipmapNode::ACTIVE || node->collision_body.is_valid() ||
!node->is_leaf()) {
queue_.erase(queue_.begin() + i);
}
}
// Cap queue at 512 — drop most distant entries to bound memory/sort cost
if (queue_.size() > 512) {
std::sort(queue_.begin(), queue_.end(),
[&cam_pos, voxel_size](const CollisionRequest& a, const CollisionRequest& b) {
auto dist_sq = [&](const CollisionRequest& r) {
float cs = cell_world_size(r.node->lod_level, voxel_size, 32);
float cx = r.node->position.x * voxel_size + cs * 0.5f;
float cy = r.node->position.y * voxel_size + cs * 0.5f;
float cz = r.node->position.z * voxel_size + cs * 0.5f;
float dx = cx - cam_pos[0], dy = cy - cam_pos[1], dz = cz - cam_pos[2];
return dx*dx + dy*dy + dz*dz;
};
return dist_sq(a) < dist_sq(b);
});
queue_.resize(512);
}
while (collisions_built < max_collisions_per_frame && !queue_.empty()) {
// Find closest request to camera
int best_idx = 0;
float best_dist = 1e30f;
for (int i = 0; i < static_cast<int>(queue_.size()); i++) {
auto* node = queue_[i].node;
float cs = cell_world_size(node->lod_level, voxel_size, 32);
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 dx = cx - cam_pos[0], dy = cy - cam_pos[1], dz = cz - cam_pos[2];
float d = dx*dx + dy*dy + dz*dz;
if (d < best_dist) { best_dist = d; best_idx = i; }
}
CollisionRequest creq = std::move(queue_[best_idx]);
queue_.erase(queue_.begin() + best_idx);
auto* cnode = creq.node;
if (cnode && cnode->generation == creq.node_generation &&
cnode->state == OctreeClipmapNode::ACTIVE && !cnode->collision_body.is_valid() &&
cnode->is_leaf()) { // Don't build collision for nodes that split during queue wait
PhysicsServer3D* ps = PhysicsServer3D::get_singleton();
if (ps && world.is_valid() && creq.faces.size() >= 3) {
RID body = ps->body_create();
ps->body_set_mode(body, PhysicsServer3D::BODY_MODE_STATIC);
RID shape = ps->concave_polygon_shape_create();
Dictionary shape_data;
shape_data["faces"] = creq.faces;
// backface_collision IS REQUIRED, not optional.
//
// GodotPhysics3D defaults it when the key is absent, so
// omitting it worked and looked fine. Jolt does not:
// JoltConcavePolygonShape3D::set_data ERR_FAIL_CONDs unless
// the dictionary carries a BOOL under this key, and on that
// failure the shape keeps NO faces at all -- so every
// terrain chunk became an empty collision body and the world
// had no collision whatsoever, with the only symptom being
// 102 identical errors naming a file most people never open.
//
// false is the right value and it is measured, not assumed:
// hyper-tps scenes/test/trimesh_winding_gate.gd drops the
// shipping capsule on a floor of each winding and reports
// which one collides. A standable floor has (b-a)x(c-a)
// pointing DOWN, which is what the mesher emits, so the
// front face is the one you walk on and back faces should
// not collide.
shape_data["backface_collision"] = false;
ps->shape_set_data(shape, shape_data);
ps->body_add_shape(body, shape);
cnode->collision_shape = shape;
ps->body_set_space(body, world->get_space());
float col_lod_voxel = lod_voxel_size(voxel_size, cnode->lod_level);
float col_overlap = 2.0f * col_lod_voxel; // overlap = 2 voxels (must match visual mesh chunk_origin)
Transform3D col_xform;
col_xform.origin = Vector3(
cnode->position.x * voxel_size - col_overlap,
cnode->position.y * voxel_size - col_overlap,
cnode->position.z * voxel_size - col_overlap);
ps->body_set_state(body, PhysicsServer3D::BODY_STATE_TRANSFORM, col_xform);
cnode->collision_body = body;
collisions_built++;
if (debug_logging) {
int tri_count = creq.faces.size() / 3;
fprintf(stderr, "COLLISION built LOD%d pos=(%d,%d,%d) tris=%d dist=%.0f q_remaining=%d\n",
cnode->lod_level,
cnode->position.x, cnode->position.y, cnode->position.z,
tri_count, sqrtf(best_dist),
static_cast<int>(queue_.size()));
}
}
}
}
return collisions_built;
}
private:
std::vector<CollisionRequest> queue_; // near (<= collision_distance): buildable; counts toward queue_size()
std::vector<CollisionRequest> deferred_; // beyond collision_distance: held (not built, not counted), promoted on approach
};
} // namespace godot
#endif // DC_COLLISION_TRIMESH_H