OverviewFilesCommitsIssuesDocsReleasesPeers

dc-terrain / libdc / src / dc_level_mesh.cpp

184 lines · cpp ref:
#include "dc_level_mesh.h"

#include <cmath>
#include <algorithm>

namespace {

typedef CSG4Vec3 V3;
inline V3 sub(V3 a, V3 b)    { return {a.x-b.x, a.y-b.y, a.z-b.z}; }
inline float dot(V3 a, V3 b) { return a.x*b.x + a.y*b.y + a.z*b.z; }
inline V3 cross(V3 a, V3 b)  {
    return {a.y*b.z - a.z*b.y, a.z*b.x - a.x*b.z, a.x*b.y - a.y*b.x};
}
inline float len(V3 a)       { return std::sqrt(dot(a,a)); }

} // namespace

void DCLevelMeshData::clear() {
    positions.clear(); normals.clear(); material.clear();
    proj.clear(); brush_id.clear(); indices.clear();
    lo = hi = {0,0,0};
    bounds_valid = false;
}

namespace dc_level_mesh {

void build(const std::vector<CSG4Face>& faces, DCLevelMeshData& out) {
    out.clear();

    // Upper bound: every winding contributes all its points, unwelded.
    size_t pts = 0, tris = 0;
    for (const CSG4Face& f : faces) {
        if (f.pts.size() < 3) continue;
        pts  += f.pts.size();
        tris += f.pts.size() - 2;
    }
    out.positions.reserve(pts * 3);
    out.normals.reserve(pts * 3);
    out.material.reserve(pts);
    out.proj.reserve(pts);
    out.brush_id.reserve(pts);
    out.indices.reserve(tris * 3);

    V3 lo{ 1e30f,  1e30f,  1e30f};
    V3 hi{-1e30f, -1e30f, -1e30f};
    bool any = false;

    for (const CSG4Face& f : faces) {
        const size_t n = f.pts.size();
        if (n < 3) continue;

        // The face's own plane normal, recovered from the winding. Using the
        // winding (rather than re-reading the brush plane) keeps this function
        // dependent only on `faces`, and makes the normal agree with the
        // triangle orientation by construction — which is exactly what
        // max_normal_deviation() then verifies.
        V3 acc{0,0,0};
        for (size_t i = 1; i + 1 < n; i++)
            acc = { acc.x + cross(sub(f.pts[i], f.pts[0]), sub(f.pts[i+1], f.pts[0])).x,
                    acc.y + cross(sub(f.pts[i], f.pts[0]), sub(f.pts[i+1], f.pts[0])).y,
                    acc.z + cross(sub(f.pts[i], f.pts[0]), sub(f.pts[i+1], f.pts[0])).z };
        const float al = len(acc);
        if (al < 1e-20f) continue;             // degenerate sliver — drop it
        const V3 fn = { acc.x/al, acc.y/al, acc.z/al };

        const int32_t base = (int32_t)(out.positions.size() / 3);
        for (const V3& p : f.pts) {
            out.positions.push_back(p.x);
            out.positions.push_back(p.y);
            out.positions.push_back(p.z);
            out.normals.push_back(fn.x);
            out.normals.push_back(fn.y);
            out.normals.push_back(fn.z);
            out.material.push_back(f.material_id);
            out.proj.push_back(f.proj);
            out.brush_id.push_back(f.brush_id);

            lo.x = std::min(lo.x, p.x); hi.x = std::max(hi.x, p.x);
            lo.y = std::min(lo.y, p.y); hi.y = std::max(hi.y, p.y);
            lo.z = std::min(lo.z, p.z); hi.z = std::max(hi.z, p.z);
            any = true;
        }
        // Fan triangulation, emitted in REVERSED winding order.
        //
        // csg4 orders each winding counter-clockwise about its outward plane
        // normal, so the natural fan (base, i, i+1) gives a geometric normal of
        // +fn. Godot's convention is the OPPOSITE: its front faces are clockwise
        // viewed from the front, i.e. cross(v1-v0, v2-v0) points AWAY from the
        // shading normal. [MEASURED] BoxMesh and SphereMesh both agree 0% with
        // their own shading normals under that cross product; the un-reversed fan
        // agreed 100%. Emitting (base, i+1, i) makes outward faces front-facing so
        // the level renders solid from outside instead of inside-out.
        //
        // Positions and shading normals are untouched — only the triangle
        // orientation changes, so lighting still uses the true outward normal.
        // The collider path is unaffected: it hulls UNCLIPPED brush windings and
        // ConvexPolygonShape3D derives its own hull, so point order is irrelevant.
        for (size_t i = 1; i + 1 < n; i++) {
            out.indices.push_back(base);
            out.indices.push_back(base + (int32_t)i + 1);
            out.indices.push_back(base + (int32_t)i);
        }
    }

    if (any) { out.lo = lo; out.hi = hi; out.bounds_valid = true; }
}

void build_colliders(const std::vector<CSG4Brush>& brushes,
                     std::vector<DCConvexCollider>& out) {
    out.clear();
    out.reserve(brushes.size());
    std::vector<V3> w;

    for (const CSG4Brush& b : brushes) {
        // Sky brushes are NON-SOLID: you can jump through them. They still reached
        // csg4 and clipped their neighbours (they are real, visible geometry) —
        // only the collider is skipped. This is the render/collide split the §10
        // note already draws for the opposite reason: rendering uses the CLIPPED
        // skin, collision the UNCLIPPED volume, and the two sets need not match.
        if (b.sky) continue;
        // Clip-only solids get NO collider. The carved remainder is the case:
        // its volume must keep clipping neighbours, but a convex shape over it
        // would re-solidify exactly the volume the carve removed — the player
        // sees the hole and cannot walk into it.
        if (b.clip_only) continue;
        DCConvexCollider c;
        c.brush_id = b.id;
        std::vector<V3> hull;

        // UNCLIPPED windings — collision is about volume, not visible surface.
        for (uint32_t fi = 0; fi < b.plane_count; fi++) {
            if (!dc_csg4::face_winding(b, fi, w)) continue;
            for (const V3& p : w) {
                bool dup = false;
                for (const V3& q : hull)
                    if (len(sub(q, p)) < 1e-4f) { dup = true; break; }
                if (!dup) hull.push_back(p);
            }
        }
        if (hull.size() < 4) continue;   // not a solid; nothing to collide with

        c.points.reserve(hull.size() * 3);
        for (const V3& p : hull) {
            c.points.push_back(p.x);
            c.points.push_back(p.y);
            c.points.push_back(p.z);
        }
        out.push_back(std::move(c));
    }
}

float max_normal_deviation(const DCLevelMeshData& m) {
    float worst = 0.0f;
    const uint32_t tris = m.triangle_count();
    for (uint32_t t = 0; t < tris; t++) {
        const int32_t i0 = m.indices[t*3 + 0];
        const int32_t i1 = m.indices[t*3 + 1];
        const int32_t i2 = m.indices[t*3 + 2];
        const V3 p0{ m.positions[i0*3], m.positions[i0*3+1], m.positions[i0*3+2] };
        const V3 p1{ m.positions[i1*3], m.positions[i1*3+1], m.positions[i1*3+2] };
        const V3 p2{ m.positions[i2*3], m.positions[i2*3+1], m.positions[i2*3+2] };
        const V3 g = cross(sub(p1, p0), sub(p2, p0));
        const float gl = len(g);
        if (gl < 1e-12f) continue;                 // degenerate triangle
        // NEGATED: build() emits Godot-convention windings, where a front face's
        // cross(v1-v0, v2-v0) points AWAY from the shading normal. So the EXPECTED
        // relationship is gn == -sn, and -gn is what should align with sn.
        //
        // Keeping this orientation-aware (rather than using |dot|) is deliberate:
        // it still measures how far each triangle tilts out of its source face's
        // plane — the "sharp edges" number — AND it now fails loudly at ~180 deg if
        // the winding is ever flipped again. An abs() here would have made this
        // metric blind to the inversion that shipped in 0.768.0, which is exactly
        // the hole that let a passing gate render inside-out.
        const V3 gn{ -g.x/gl, -g.y/gl, -g.z/gl };
        const V3 sn{ m.normals[i0*3], m.normals[i0*3+1], m.normals[i0*3+2] };
        const float c = std::max(-1.0f, std::min(1.0f, dot(gn, sn)));
        worst = std::max(worst, std::acos(c));
    }
    return worst;
}

} // namespace dc_level_mesh