OverviewFilesCommitsIssuesDocsReleasesPeers

dc-terrain / src / dc_collision_trimesh.h

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