Nimbin[12]?SDL & Graphics / sdl_runtime_compiler / src/text/mesh.cpp

sdl_runtime_compiler git · main

SDL3 game for running and compiling code at runtime

sdl3 c++ compiler dlopen cmake · first commit 2026-04-19 · last commit 2026-07-03 (3 months ago) · synced 3 days ago · upstream: git.ide3.de/hsnr/sdl-runtime-compiler

C++ 72.3% C 26.2%
git clone https://git.christianimmanuel.de/sdl-graphics/sdl_runtime_compiler.gitwget https://git.christianimmanuel.de/sdl-graphics/sdl_runtime_compiler/archive/sdl_runtime_compiler.tar.gz
src/text/mesh.cpp 14.8 KB · 405 lines raw
// text_mesh.cpp
//
// Pipeline:
//   FreeType glyph outline  →  contour polygons (with Bezier flattening)
//   →  classify contours as outer/hole by containment (not just winding)
//   →  earcut triangulation per outer+holes group
//   →  extrude into 3D solid (front face, back face, side walls)
//
// Dependencies: freetype2, earcut.hpp (header-only)

#include "mesh.hpp"
#include "thirdparty/earcut/earcut.hpp"
#include "token.hpp"

#include <SDL3/SDL.h>
#include <algorithm>
#include <array>
#include <ft2build.h>
#include <unordered_map>

namespace
Nimbin
{
#include FT_FREETYPE_H
#include FT_OUTLINE_H


static constexpr int   BEZIER_STEPS = 8;
static constexpr float FT_SCALE     = 1.0f / 64.0f;

using Pt2  = std::array<float, 2>;
using Ring = DynArray<Pt2>;
using Poly = DynArray<Ring>;   // [0]=outer, [1..]=holes




namespace {

struct FaceKey {
    std::string path;
    int         size;
    bool operator==(const FaceKey& o) const { return size == o.size && path == o.path; }
};
struct FaceKeyHash {
    size_t operator()(const FaceKey& k) const {
        return std::hash<std::string>{}(k.path) ^ (std::hash<int>{}(k.size) << 1);
    }
};

FT_Library g_ft = nullptr;
std::unordered_map<FaceKey, FT_Face, FaceKeyHash> g_faces;

// Returns a ready-to-use face at the requested pixel size, or nullptr.
FT_Face acquire_Face(const std::string& path, int size) {
    if (!g_ft && FT_Init_FreeType(&g_ft)) { g_ft = nullptr; return nullptr; }

    FaceKey key{ path, size };
    auto it = g_faces.find(key);
    if (it != g_faces.end()) {
        FT_Set_Pixel_Sizes(it->second, 0, static_cast<FT_UInt>(size));  // cheap; face stays loaded
        return it->second;
    }

    FT_Face face;
    if (FT_New_Face(g_ft, path.c_str(), 0, &face)) {
        SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "FT_New_Face failed: %s", path.c_str());
        return nullptr;
    }
    FT_Set_Pixel_Sizes(face, 0, static_cast<FT_UInt>(size));
    g_faces.emplace(key, face);
    return face;
}

} // namespace

// ── FreeType outline callbacks ────────────────────────────────────────────────

struct OutlineCtx {
    DynArray<Ring> rings;
    Ring              current;
    Pt2               pen{};
    void flush() {
        if (current.size() >= 3) rings.push_back(std::move(current));
        current.clear();
    }
};

static int ftMoveTo(const FT_Vector* to, void* user) {
    OutlineCtx* c = static_cast<OutlineCtx*>(user);
    c->flush();
    c->pen = { static_cast<float>(to->x)*FT_SCALE, static_cast<float>(to->y)*FT_SCALE };
    c->current.push_back(c->pen);
    return 0;
}
static int ftLineTo(const FT_Vector* to, void* user) {
    OutlineCtx* c = static_cast<OutlineCtx*>(user);
    c->pen = { static_cast<float>(to->x)*FT_SCALE, static_cast<float>(to->y)*FT_SCALE };
    c->current.push_back(c->pen);
    return 0;
}
static int ftConicTo(const FT_Vector* ctrl, const FT_Vector* to, void* user) {
    OutlineCtx* c = static_cast<OutlineCtx*>(user);
    float p0x=c->pen[0], p0y=c->pen[1];
    float p1x=static_cast<float>(ctrl->x)*FT_SCALE, p1y=static_cast<float>(ctrl->y)*FT_SCALE;
    float p2x=static_cast<float>(to->x  )*FT_SCALE, p2y=static_cast<float>(to->y  )*FT_SCALE;
    for (int i=1; i<=BEZIER_STEPS; i++) {
        float t=static_cast<float>(i)/static_cast<float>(BEZIER_STEPS), mt=1.0f-t;
        c->current.push_back({ mt*mt*p0x+2*mt*t*p1x+t*t*p2x,
                                mt*mt*p0y+2*mt*t*p1y+t*t*p2y });
    }
    c->pen = {p2x,p2y};
    return 0;
}
static int ftCubicTo(const FT_Vector* c1, const FT_Vector* c2, const FT_Vector* to, void* user) {
    OutlineCtx* c = static_cast<OutlineCtx*>(user);
    float p0x=c->pen[0], p0y=c->pen[1];
    float p1x=static_cast<float>(c1->x)*FT_SCALE, p1y=static_cast<float>(c1->y)*FT_SCALE;
    float p2x=static_cast<float>(c2->x)*FT_SCALE, p2y=static_cast<float>(c2->y)*FT_SCALE;
    float p3x=static_cast<float>(to->x)*FT_SCALE, p3y=static_cast<float>(to->y)*FT_SCALE;
    for (int i=1; i<=BEZIER_STEPS; i++) {
        float t=static_cast<float>(i)/static_cast<float>(BEZIER_STEPS), mt=1.0f-t;
        c->current.push_back({ mt*mt*mt*p0x+3*mt*mt*t*p1x+3*mt*t*t*p2x+t*t*t*p3x,
                                mt*mt*mt*p0y+3*mt*mt*t*p1y+3*mt*t*t*p2y+t*t*t*p3y });
    }
    c->pen = {p3x,p3y};
    return 0;
}
static const FT_Outline_Funcs kFTFuncs = { ftMoveTo, ftLineTo, ftConicTo, ftCubicTo, 0, 0 };

// ── Contour helpers ───────────────────────────────────────────────────────────

// Signed area: positive = CCW (outer in FT Y-up), negative = CW (hole)
static float signedArea(const Ring& r) {
    float a = 0.0f;
    size_t n = r.size();
    for (size_t i = 0, j = n-1; i < n; j = i++)
        a += (r[j][0]+r[i][0]) * (r[j][1]-r[i][1]);
    return a * 0.5f;
}

// Axis-aligned bounding box of a ring
struct AABB { float minx,miny,maxx,maxy; };
static AABB ringAABB(const Ring& r) {
    AABB b{ r[0][0],r[0][1],r[0][0],r[0][1] };
    for (auto& p : r) {
        if (p[0]<b.minx) b.minx=p[0];
        if (p[0]>b.maxx) b.maxx=p[0];
        if (p[1]<b.miny) b.miny=p[1];
        if (p[1]>b.maxy) b.maxy=p[1];
    }
    return b;
}

// Does AABB A fully contain AABB B?
static bool aabbContains(const AABB& a, const AABB& b) {
    return b.minx>=a.minx && b.maxx<=a.maxx && b.miny>=a.miny && b.maxy<=a.maxy;
}

// ── Build polygons: group contours into (outer + its holes) ──────────────────
// FreeType guarantees: CCW = outer contour, CW = hole.
// For glyphs like 'i', 'j', '!', there are multiple separate outer contours.
// Each CW (hole) ring belongs to the outer ring whose AABB contains it.
// If no outer contains it, treat it as its own outer (handles edge cases).

static DynArray<Poly> groupContours(DynArray<Ring>& rings, float pen_x, float scale)
{
    struct RingInfo { Ring r; AABB bb; bool is_hole; };
    DynArray<RingInfo> info;
    info.reserve(rings.size());
    for (auto& ring : rings) {
        Ring shifted;
        shifted.reserve(ring.size());
        for (auto& p : ring)
            shifted.push_back({ (p[0]+pen_x)*scale, p[1]*scale });
        AABB bb = ringAABB(shifted);
        bool hole = (signedArea(ring) < 0.0f);  // original coords for winding
        info.push_back({ std::move(shifted), bb, hole });
    }

    // Collect outers
    DynArray<size_t> outer_ids, hole_ids;
    for (size_t i = 0; i < info.size(); i++)
        (info[i].is_hole ? hole_ids : outer_ids).push_back(i);

    // Each outer starts its own Poly
    DynArray<Poly> polys;
    for (size_t oi : outer_ids) {
        Poly p;
        p.push_back(info[oi].r);
        polys.push_back(std::move(p));
    }

    // Assign each hole to the smallest containing outer
    for (size_t hi : hole_ids) {
        float best_area = 1e18f;
        int   best_poly = -1;
        for (int pi = 0; pi < static_cast<int>(polys.size()); pi++) {
            AABB ob = ringAABB(polys[static_cast<size_t>(pi)][0]);
            if (aabbContains(ob, info[hi].bb)) {
                float a = (ob.maxx-ob.minx)*(ob.maxy-ob.miny);
                if (a < best_area) { best_area=a; best_poly=pi; }
            }
        }
        if (best_poly >= 0)
            polys[static_cast<size_t>(best_poly)].push_back(info[hi].r);
        else {
            // No outer contains it — treat as standalone outer (shouldn't happen)
            Poly p; p.push_back(info[hi].r);
            polys.push_back(std::move(p));
        }
    }

    return polys;
}

// ── Main builder ──────────────────────────────────────────────────────────────
void computeVertexNormals(TextMesh& mesh)
{
    mesh.vertex_normals.assign(mesh.verts.size(), Vec3{0,0,0});

    for (const MeshTri& t : mesh.tris) {
        const Vec3& v0 = mesh.verts[static_cast<size_t>(t.a)];
        const Vec3& v1 = mesh.verts[static_cast<size_t>(t.b)];
        const Vec3& v2 = mesh.verts[static_cast<size_t>(t.c)];

        Vec3 e1 = v1 - v0;
        Vec3 e2 = v2 - v0;

        Vec3 n{
            e1.y * e2.z - e1.z * e2.y,
            e1.z * e2.x - e1.x * e2.z,
            e1.x * e2.y - e1.y * e2.x
        };

        // Accumulate weighted by area (len of cross product = 2*area)
        mesh.vertex_normals[static_cast<size_t>(t.a)] += n;
        mesh.vertex_normals[static_cast<size_t>(t.b)] += n;
        mesh.vertex_normals[static_cast<size_t>(t.c)] += n;
    }

    for (Vec3& n : mesh.vertex_normals) {
        double len = SDL_sqrt(n.x*n.x + n.y*n.y + n.z*n.z);
        if (len > 0.000001) {
            n.x /= len; n.y /= len; n.z /= len;
        }
        else {
            n = {0, 0, -1}; // fallback: face forward
        }
    }
}


// ── Per-glyph geometry cache ──────────────────────────────────────────────────
// A glyph's tessellated+extruded geometry depends only on (codepoint, scale)
// now that depth is a draw-time Z-scale (mesh built at unit depth, faces ±0.5).
// Build each glyph once; build() just stitches cached glyphs with an x-offset.
namespace {

struct GlyphKey {
    uint32_t ch;
    int      scale_q;                       // scale quantised → stable float key
    bool operator==(const GlyphKey& o) const { return ch == o.ch && scale_q == o.scale_q; }
};
struct GlyphKeyHash {
    size_t operator()(const GlyphKey& k) const {
        return std::hash<uint32_t>{}(k.ch) * 1000003u ^ static_cast<size_t>(k.scale_q);
    }
};

struct GlyphMesh {
    TextMesh m;                 // verts/tris/vertex_normals/is_front, at origin, unit depth
    float    advance = 0.0f;    // scaled world-unit x advance
};

std::unordered_map<GlyphKey, GlyphMesh, GlyphKeyHash> g_glyphs;

// Extrude one glyph's polys into `out` at unit depth (faces at z = ±0.5).
void
extrude_Polys(TextMesh& out, const DynArray<Poly>& polys)
{
    constexpr float half_d = 0.5f;          // UNIT depth — scaled per-instance at draw

    for (const Poly& poly : polys) {
        if (poly.empty() || poly[0].size() < 3) continue;

        DynArray<uint32_t> indices = mapbox::earcut<uint32_t>(poly);
        if (indices.empty()) continue;

        DynArray<Pt2> flat;
        for (auto& ring : poly) for (auto& p : ring) flat.push_back(p);

        int base = static_cast<int>(out.verts.size());

        for (auto& p : flat) {              // front (z = -0.5)
            out.verts.push_back({ (double)p[0], (double)p[1], (double)(-half_d) });
            out.is_front.push_back(1.0f);
        }
        int back_base = static_cast<int>(out.verts.size());
        for (auto& p : flat) {              // back (z = +0.5)
            out.verts.push_back({ (double)p[0], (double)p[1], (double)( half_d) });
            out.is_front.push_back(0.0f);
        }

        for (size_t i = 0; i + 2 < indices.size(); i += 3)
            out.tris.push_back({ base + (int)indices[i], base + (int)indices[i+2], base + (int)indices[i+1] });
        for (size_t i = 0; i + 2 < indices.size(); i += 3)
            out.tris.push_back({ back_base + (int)indices[i], back_base + (int)indices[i+1], back_base + (int)indices[i+2] });

        for (const auto& ring : poly) {     // side walls
            int rn = static_cast<int>(ring.size());
            for (int i = 0; i < rn; i++) {
                int i1 = (i + 1) % rn;
                int wb = static_cast<int>(out.verts.size());
                const Pt2& p0 = ring[(size_t)i];
                const Pt2& p1 = ring[(size_t)i1];
                out.verts.push_back({ (double)p0[0], (double)p0[1], (double)(-half_d) }); out.is_front.push_back(0.0f);
                out.verts.push_back({ (double)p1[0], (double)p1[1], (double)(-half_d) }); out.is_front.push_back(0.0f);
                out.verts.push_back({ (double)p1[0], (double)p1[1], (double)( half_d) }); out.is_front.push_back(0.0f);
                out.verts.push_back({ (double)p0[0], (double)p0[1], (double)( half_d) }); out.is_front.push_back(0.0f);
                out.tris.push_back({ wb+0, wb+1, wb+2 });
                out.tris.push_back({ wb+0, wb+2, wb+3 });
            }
        }
    }
}

const GlyphMesh*
acquire_Glyph(FT_Face face, uint32_t ch, float scale)
{
    GlyphKey key{ ch, (int)SDL_lround((double)scale * 1e7) };
    auto it = g_glyphs.find(key);
    if (it != g_glyphs.end()) return &it->second;
    // SDL_Log("GLYPH MISS '%c' scale_q=%d (cache=%zu)", (char)ch, key.scale_q, g_glyphs.size());


    GlyphMesh gm;
    FT_UInt gi = FT_Get_Char_Index(face, (FT_ULong)ch);

    if (!FT_Load_Glyph(face, gi, FT_LOAD_NO_BITMAP) &&
        face->glyph->format == FT_GLYPH_FORMAT_OUTLINE)
    {
        gm.advance = (float)face->glyph->advance.x * FT_SCALE * scale;

        OutlineCtx ctx;
        FT_Outline_Decompose(&face->glyph->outline, &kFTFuncs, &ctx);
        ctx.flush();
        if (!ctx.rings.empty()) {
            DynArray<Poly> polys = groupContours(ctx.rings, 0.0f, scale);  // origin, scaled
            extrude_Polys(gm.m, polys);
            computeVertexNormals(gm.m);                                    // per-glyph, once
        }
    }
    // failed load / non-outline → advance 0, no geometry (matches old skip)

    auto [ins, ok] = g_glyphs.emplace(std::move(key), std::move(gm));
    return &ins->second;
}

} // namespace


bool
TextMesh::build(const std::string& font_path,
                const std::string& text, int size, float /*depth*/, float scale)
{
    //SPIKE("mesh.build");
    verts.clear(); tris.clear(); is_front.clear(); vertex_normals.clear();

    FT_Face face = acquire_Face(font_path, size);
    if (!face) return false;

    float pen_x = 0.0f;                       // scaled world units
    for (const char chc : text) {
        const GlyphMesh* g = acquire_Glyph(face, (unsigned char)chc, scale);
        if (!g) continue;

        const int base = static_cast<int>(verts.size());
        for (const Vec3& v : g->m.verts)
            verts.push_back({ v.x + (double)pen_x, v.y, v.z });   // z stays unit (±0.5)
        for (const Vec3& n : g->m.vertex_normals) vertex_normals.push_back(n);
        for (float f         : g->m.is_front)     is_front.push_back(f);
        for (const MeshTri& t : g->m.tris)
            tris.push_back({ t.a + base, t.b + base, t.c + base });

        pen_x += g->advance;
    }

    // Centre on bounding box (x only) — unchanged.
    if (!verts.empty()) {
        float minX = 1e9f, maxX = -1e9f;
        for (const Vec3& v : verts) {
            float vx = (float)v.x;
            if (vx < minX) minX = vx;
            if (vx > maxX) maxX = vx;
        }
        double cx = (double)((minX + maxX) * 0.5f);
        for (Vec3& v : verts) v.x -= cx;
    }

    // Normals are cached per glyph — no whole-word recompute needed.
    return !verts.empty();
}

} // namespace Nimbin