Replace stb_truetype with FreeType for proper text outline rendering

- Add -sUSE_FREETYPE=1 to Emscripten build flags
- Extend Font class with FT_Library, FT_Face, and FT_Stroker handles
- Rewrite FontAtlas to use FreeType with on-demand stroked glyph loading
- Text outlines now use FT_Stroker for vector-based stroking before
  rasterization, eliminating gaps at corners with thick outlines
- Use glTexSubImage2D for incremental atlas updates (major perf fix)
- Disable canvas border in shell.html per Emscripten docs (alignment fix)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-01-31 16:45:10 -05:00
commit 67aa413a78
5 changed files with 538 additions and 128 deletions

View file

@ -283,12 +283,14 @@ if(EMSCRIPTEN)
-sFULL_ES2=1 -sFULL_ES2=1
-sMIN_WEBGL_VERSION=2 -sMIN_WEBGL_VERSION=2
-sMAX_WEBGL_VERSION=2 -sMAX_WEBGL_VERSION=2
-sUSE_FREETYPE=1
) )
# SDL2 flags are also needed at compile time for headers # SDL2 and FreeType flags are also needed at compile time for headers
target_compile_options(mcrogueface PRIVATE target_compile_options(mcrogueface PRIVATE
-sUSE_SDL=2 -sUSE_SDL=2
-sUSE_FREETYPE=1
) )
message(STATUS "Emscripten SDL2 options enabled: -sUSE_SDL=2 -sFULL_ES2=1") message(STATUS "Emscripten SDL2 options enabled: -sUSE_SDL=2 -sFULL_ES2=1 -sUSE_FREETYPE=1")
endif() endif()
target_link_options(mcrogueface PRIVATE ${EMSCRIPTEN_LINK_OPTIONS}) target_link_options(mcrogueface PRIVATE ${EMSCRIPTEN_LINK_OPTIONS})

View file

@ -26,12 +26,16 @@
#include <GL/gl.h> #include <GL/gl.h>
#endif #endif
// stb libraries for image/font loading (from deps/stb/) // stb_image for image loading (from deps/stb/)
#define STB_IMAGE_IMPLEMENTATION #define STB_IMAGE_IMPLEMENTATION
#include <stb_image.h> #include <stb_image.h>
#define STB_TRUETYPE_IMPLEMENTATION // FreeType for font loading and text rendering with proper outline support
#include <stb_truetype.h> #include <ft2build.h>
#include FT_FREETYPE_H
#include FT_GLYPH_H
#include FT_STROKER_H
#include FT_OUTLINE_H
namespace sf { namespace sf {
@ -1205,12 +1209,29 @@ bool Image::saveToFile(const std::string& filename) const {
} }
// ============================================================================= // =============================================================================
// Font Implementation // Font Implementation (FreeType-based)
// ============================================================================= // =============================================================================
Font::~Font() {
if (ftStroker_) {
FT_Stroker_Done(static_cast<FT_Stroker>(ftStroker_));
ftStroker_ = nullptr;
}
if (ftFace_) {
FT_Done_Face(static_cast<FT_Face>(ftFace_));
ftFace_ = nullptr;
}
if (ftLibrary_) {
FT_Done_FreeType(static_cast<FT_Library>(ftLibrary_));
ftLibrary_ = nullptr;
}
}
bool Font::loadFromFile(const std::string& filename) { bool Font::loadFromFile(const std::string& filename) {
// Read file into memory first (FreeType needs persistent data)
FILE* file = fopen(filename.c_str(), "rb"); FILE* file = fopen(filename.c_str(), "rb");
if (!file) { if (!file) {
std::cerr << "Font: Failed to open file: " << filename << std::endl;
return false; return false;
} }
@ -1222,13 +1243,81 @@ bool Font::loadFromFile(const std::string& filename) {
fread(fontData_.data(), 1, size, file); fread(fontData_.data(), 1, size, file);
fclose(file); fclose(file);
// Initialize FreeType library
FT_Library library;
if (FT_Init_FreeType(&library) != 0) {
std::cerr << "Font: Failed to initialize FreeType library" << std::endl;
return false;
}
ftLibrary_ = library;
// Create face from memory (font data must persist!)
FT_Face face;
if (FT_New_Memory_Face(library, fontData_.data(), fontData_.size(), 0, &face) != 0) {
std::cerr << "Font: Failed to create FreeType face from: " << filename << std::endl;
FT_Done_FreeType(library);
ftLibrary_ = nullptr;
return false;
}
ftFace_ = face;
// Select Unicode charmap
FT_Select_Charmap(face, FT_ENCODING_UNICODE);
// Create stroker for outline rendering
FT_Stroker stroker;
if (FT_Stroker_New(library, &stroker) != 0) {
std::cerr << "Font: Failed to create FreeType stroker" << std::endl;
FT_Done_Face(face);
FT_Done_FreeType(library);
ftFace_ = nullptr;
ftLibrary_ = nullptr;
return false;
}
ftStroker_ = stroker;
loaded_ = true; loaded_ = true;
std::cout << "Font: Loaded " << filename << " with FreeType" << std::endl;
return true; return true;
} }
bool Font::loadFromMemory(const void* data, size_t sizeInBytes) { bool Font::loadFromMemory(const void* data, size_t sizeInBytes) {
fontData_.resize(sizeInBytes); fontData_.resize(sizeInBytes);
memcpy(fontData_.data(), data, sizeInBytes); memcpy(fontData_.data(), data, sizeInBytes);
// Initialize FreeType library
FT_Library library;
if (FT_Init_FreeType(&library) != 0) {
std::cerr << "Font: Failed to initialize FreeType library" << std::endl;
return false;
}
ftLibrary_ = library;
// Create face from memory
FT_Face face;
if (FT_New_Memory_Face(library, fontData_.data(), fontData_.size(), 0, &face) != 0) {
std::cerr << "Font: Failed to create FreeType face from memory" << std::endl;
FT_Done_FreeType(library);
ftLibrary_ = nullptr;
return false;
}
ftFace_ = face;
// Select Unicode charmap
FT_Select_Charmap(face, FT_ENCODING_UNICODE);
// Create stroker for outline rendering
FT_Stroker stroker;
if (FT_Stroker_New(library, &stroker) != 0) {
std::cerr << "Font: Failed to create FreeType stroker" << std::endl;
FT_Done_Face(face);
FT_Done_FreeType(library);
ftFace_ = nullptr;
ftLibrary_ = nullptr;
return false;
}
ftStroker_ = stroker;
loaded_ = true; loaded_ = true;
return true; return true;
} }
@ -1599,7 +1688,7 @@ void Sprite::draw(RenderTarget& target, RenderStates states) const {
texture_->getNativeHandle(), SDL2Renderer::ShaderType::Sprite); texture_->getNativeHandle(), SDL2Renderer::ShaderType::Sprite);
} }
// Static cache for font atlases - keyed by (font data pointer, character size) // Static cache for font atlases - keyed by (font pointer, character size)
static std::map<std::pair<const Font*, unsigned int>, FontAtlas> s_fontAtlasCache; static std::map<std::pair<const Font*, unsigned int>, FontAtlas> s_fontAtlasCache;
void Text::draw(RenderTarget& target, RenderStates states) const { void Text::draw(RenderTarget& target, RenderStates states) const {
@ -1610,17 +1699,24 @@ void Text::draw(RenderTarget& target, RenderStates states) const {
auto it = s_fontAtlasCache.find(key); auto it = s_fontAtlasCache.find(key);
if (it == s_fontAtlasCache.end()) { if (it == s_fontAtlasCache.end()) {
FontAtlas atlas; FontAtlas atlas;
// Use the new Font-based loader if FreeType is available, fall back to legacy
if (font_->getFTFace()) {
if (!atlas.load(font_, static_cast<float>(characterSize_))) {
return; // Failed to create atlas
}
} else {
if (!atlas.load(font_->getData(), font_->getDataSize(), static_cast<float>(characterSize_))) { if (!atlas.load(font_->getData(), font_->getDataSize(), static_cast<float>(characterSize_))) {
return; // Failed to create atlas return; // Failed to create atlas
} }
}
it = s_fontAtlasCache.emplace(key, std::move(atlas)).first; it = s_fontAtlasCache.emplace(key, std::move(atlas)).first;
} }
const FontAtlas& atlas = it->second; FontAtlas& atlas = it->second; // Non-const for on-demand glyph loading
Transform combined = states.transform * getTransform(); Transform combined = states.transform * getTransform();
// Helper lambda to build glyph geometry with a given color and offset // Helper lambda to build glyph geometry with a given color and outline thickness
auto buildGlyphs = [&](const Color& color, float offsetX, float offsetY, auto buildGlyphs = [&](const Color& color, float outlineThickness,
std::vector<float>& verts, std::vector<float>& uvs, std::vector<float>& cols) { std::vector<float>& verts, std::vector<float>& uvs, std::vector<float>& cols) {
float x = 0; float x = 0;
float y = atlas.getAscent(); float y = atlas.getAscent();
@ -1640,15 +1736,34 @@ void Text::draw(RenderTarget& target, RenderStates states) const {
} }
FontAtlas::GlyphInfo glyph; FontAtlas::GlyphInfo glyph;
if (!atlas.getGlyph(static_cast<uint32_t>(c), glyph)) { // Use stroked glyph lookup for outlines, regular for fill
if (!atlas.getGlyph(' ', glyph)) { bool found = false;
continue; if (outlineThickness > 0) {
} found = atlas.getGlyph(static_cast<uint32_t>(c), outlineThickness, glyph);
} else {
found = atlas.getGlyph(static_cast<uint32_t>(c), glyph);
} }
// Calculate quad corners with offset if (!found) {
float x0 = x + glyph.xoff + offsetX; // Try space as fallback
float y0 = y + glyph.yoff + offsetY; if (outlineThickness > 0) {
found = atlas.getGlyph(' ', outlineThickness, glyph);
} else {
found = atlas.getGlyph(' ', glyph);
}
if (!found) continue;
}
if (glyph.width == 0 || glyph.height == 0) {
// Invisible character (space), just advance
x += glyph.xadvance;
continue;
}
// Calculate quad corners
// For stroked glyphs, the bitmap is larger and offset differently
float x0 = x + glyph.xoff;
float y0 = y + glyph.yoff;
float x1 = x0 + glyph.width; float x1 = x0 + glyph.width;
float y1 = y0 + glyph.height; float y1 = y0 + glyph.height;
@ -1676,21 +1791,12 @@ void Text::draw(RenderTarget& target, RenderStates states) const {
} }
}; };
// Draw outline first (if any) // Draw outline first using stroked glyphs (if any)
if (outlineThickness_ > 0 && outlineColor_.a > 0) { if (outlineThickness_ > 0 && outlineColor_.a > 0) {
std::vector<float> outlineVerts, outlineUVs, outlineCols; std::vector<float> outlineVerts, outlineUVs, outlineCols;
// Draw at 8 positions around each glyph for outline effect // Use FreeType stroker for proper vector-based outlines
float t = outlineThickness_; buildGlyphs(outlineColor_, outlineThickness_, outlineVerts, outlineUVs, outlineCols);
float offsets[][2] = {
{-t, -t}, {0, -t}, {t, -t},
{-t, 0}, {t, 0},
{-t, t}, {0, t}, {t, t}
};
for (auto& off : offsets) {
buildGlyphs(outlineColor_, off[0], off[1], outlineVerts, outlineUVs, outlineCols);
}
if (!outlineVerts.empty()) { if (!outlineVerts.empty()) {
SDL2Renderer::getInstance().drawTriangles( SDL2Renderer::getInstance().drawTriangles(
@ -1702,9 +1808,9 @@ void Text::draw(RenderTarget& target, RenderStates states) const {
} }
} }
// Draw fill text on top // Draw fill text on top using regular (non-stroked) glyphs
std::vector<float> vertices, texcoords, colors; std::vector<float> vertices, texcoords, colors;
buildGlyphs(fillColor_, 0, 0, vertices, texcoords, colors); buildGlyphs(fillColor_, 0.0f, vertices, texcoords, colors);
if (!vertices.empty()) { if (!vertices.empty()) {
SDL2Renderer::getInstance().drawTriangles( SDL2Renderer::getInstance().drawTriangles(
@ -1726,9 +1832,16 @@ FloatRect Text::getLocalBounds() const {
auto it = s_fontAtlasCache.find(key); auto it = s_fontAtlasCache.find(key);
if (it == s_fontAtlasCache.end()) { if (it == s_fontAtlasCache.end()) {
FontAtlas atlas; FontAtlas atlas;
// Use the new Font-based loader if FreeType is available
if (font_->getFTFace()) {
if (!atlas.load(font_, static_cast<float>(characterSize_))) {
return FloatRect(0, 0, 0, 0);
}
} else {
if (!atlas.load(font_->getData(), font_->getDataSize(), static_cast<float>(characterSize_))) { if (!atlas.load(font_->getData(), font_->getDataSize(), static_cast<float>(characterSize_))) {
return FloatRect(0, 0, 0, 0); return FloatRect(0, 0, 0, 0);
} }
}
it = s_fontAtlasCache.emplace(key, std::move(atlas)).first; it = s_fontAtlasCache.emplace(key, std::move(atlas)).first;
} }
const FontAtlas& atlas = it->second; const FontAtlas& atlas = it->second;
@ -1863,7 +1976,7 @@ bool Shader::isAvailable() {
} }
// ============================================================================= // =============================================================================
// FontAtlas Implementation // FontAtlas Implementation (FreeType-based)
// ============================================================================= // =============================================================================
FontAtlas::FontAtlas() = default; FontAtlas::FontAtlas() = default;
@ -1874,12 +1987,17 @@ FontAtlas::FontAtlas(FontAtlas&& other) noexcept
, ascent_(other.ascent_) , ascent_(other.ascent_)
, descent_(other.descent_) , descent_(other.descent_)
, lineHeight_(other.lineHeight_) , lineHeight_(other.lineHeight_)
, font_(other.font_)
, atlasPixels_(std::move(other.atlasPixels_))
, atlasX_(other.atlasX_)
, atlasY_(other.atlasY_)
, atlasRowHeight_(other.atlasRowHeight_)
, glyphCache_(std::move(other.glyphCache_)) , glyphCache_(std::move(other.glyphCache_))
, stbFontInfo_(other.stbFontInfo_) , simpleGlyphCache_(std::move(other.simpleGlyphCache_))
{ {
// Clear source to prevent double-deletion // Clear source to prevent double-deletion
other.textureId_ = 0; other.textureId_ = 0;
other.stbFontInfo_ = nullptr; other.font_ = nullptr;
} }
FontAtlas& FontAtlas::operator=(FontAtlas&& other) noexcept { FontAtlas& FontAtlas::operator=(FontAtlas&& other) noexcept {
@ -1888,9 +2006,6 @@ FontAtlas& FontAtlas::operator=(FontAtlas&& other) noexcept {
if (textureId_) { if (textureId_) {
SDL2Renderer::getInstance().deleteTexture(textureId_); SDL2Renderer::getInstance().deleteTexture(textureId_);
} }
if (stbFontInfo_) {
delete static_cast<stbtt_fontinfo*>(stbFontInfo_);
}
// Transfer ownership // Transfer ownership
textureId_ = other.textureId_; textureId_ = other.textureId_;
@ -1898,12 +2013,17 @@ FontAtlas& FontAtlas::operator=(FontAtlas&& other) noexcept {
ascent_ = other.ascent_; ascent_ = other.ascent_;
descent_ = other.descent_; descent_ = other.descent_;
lineHeight_ = other.lineHeight_; lineHeight_ = other.lineHeight_;
font_ = other.font_;
atlasPixels_ = std::move(other.atlasPixels_);
atlasX_ = other.atlasX_;
atlasY_ = other.atlasY_;
atlasRowHeight_ = other.atlasRowHeight_;
glyphCache_ = std::move(other.glyphCache_); glyphCache_ = std::move(other.glyphCache_);
stbFontInfo_ = other.stbFontInfo_; simpleGlyphCache_ = std::move(other.simpleGlyphCache_);
// Clear source to prevent double-deletion // Clear source to prevent double-deletion
other.textureId_ = 0; other.textureId_ = 0;
other.stbFontInfo_ = nullptr; other.font_ = nullptr;
} }
return *this; return *this;
} }
@ -1912,107 +2032,361 @@ FontAtlas::~FontAtlas() {
if (textureId_) { if (textureId_) {
SDL2Renderer::getInstance().deleteTexture(textureId_); SDL2Renderer::getInstance().deleteTexture(textureId_);
} }
if (stbFontInfo_) {
delete static_cast<stbtt_fontinfo*>(stbFontInfo_);
}
} }
bool FontAtlas::load(const unsigned char* fontData, size_t dataSize, float fontSize) { uint64_t FontAtlas::makeKey(uint32_t codepoint, float outlineThickness) {
fontSize_ = fontSize; // Quantize outline thickness to 0.5px increments for cache key
uint32_t outlineKey = static_cast<uint32_t>(outlineThickness * 2.0f);
return (static_cast<uint64_t>(outlineKey) << 32) | codepoint;
}
stbtt_fontinfo* info = new stbtt_fontinfo(); bool FontAtlas::load(const Font* font, float fontSize) {
if (!stbtt_InitFont(info, fontData, 0)) { if (!font || !font->isLoaded() || !font->getFTFace()) {
delete info; std::cerr << "FontAtlas: Invalid font or font not loaded" << std::endl;
return false; return false;
} }
stbFontInfo_ = info; font_ = font;
fontSize_ = fontSize;
// Get font metrics FT_Face face = static_cast<FT_Face>(font->getFTFace());
int ascent, descent, lineGap;
stbtt_GetFontVMetrics(info, &ascent, &descent, &lineGap);
float scale = stbtt_ScaleForPixelHeight(info, fontSize); // Set pixel size
ascent_ = ascent * scale; if (FT_Set_Pixel_Sizes(face, 0, static_cast<FT_UInt>(fontSize)) != 0) {
descent_ = descent * scale; std::cerr << "FontAtlas: Failed to set pixel size" << std::endl;
lineHeight_ = (ascent - descent + lineGap) * scale; return false;
}
// Create glyph atlas (simple ASCII for now) // Get font metrics (in 26.6 fixed-point format)
const int atlasSize = 512; ascent_ = face->size->metrics.ascender / 64.0f;
std::vector<unsigned char> atlasPixels(atlasSize * atlasSize, 0); descent_ = face->size->metrics.descender / 64.0f;
lineHeight_ = face->size->metrics.height / 64.0f;
int x = 1, y = 1; // Initialize atlas
int rowHeight = 0; atlasPixels_.resize(ATLAS_SIZE * ATLAS_SIZE, 0);
atlasX_ = 1;
atlasY_ = 1;
atlasRowHeight_ = 0;
// Pre-load ASCII glyphs without outline (directly, not via loadGlyph to avoid texture updates)
for (uint32_t c = 32; c < 128; ++c) { for (uint32_t c = 32; c < 128; ++c) {
int advance, lsb; FT_UInt glyphIndex = FT_Get_Char_Index(face, c);
stbtt_GetCodepointHMetrics(info, c, &advance, &lsb); if (glyphIndex == 0) continue;
int x0, y0, x1, y1; if (FT_Load_Glyph(face, glyphIndex, FT_LOAD_RENDER) != 0) continue;
stbtt_GetCodepointBitmapBox(info, c, scale, scale, &x0, &y0, &x1, &y1);
int w = x1 - x0; FT_Bitmap& bitmap = face->glyph->bitmap;
int h = y1 - y0; int w = bitmap.width;
int h = bitmap.rows;
if (x + w + 1 >= atlasSize) { GlyphInfo info;
x = 1; if (w == 0 || h == 0) {
y += rowHeight + 1; info.u0 = info.v0 = info.u1 = info.v1 = 0;
rowHeight = 0; info.xoff = 0;
info.yoff = 0;
info.xadvance = face->glyph->advance.x / 64.0f;
info.width = 0;
info.height = 0;
} else {
if (atlasX_ + w + 1 >= ATLAS_SIZE) {
atlasX_ = 1;
atlasY_ += atlasRowHeight_ + 1;
atlasRowHeight_ = 0;
}
if (atlasY_ + h + 1 >= ATLAS_SIZE) break;
for (int y = 0; y < h; ++y) {
for (int x = 0; x < w; ++x) {
atlasPixels_[(atlasY_ + y) * ATLAS_SIZE + atlasX_ + x] = bitmap.buffer[y * bitmap.pitch + x];
}
} }
if (y + h + 1 >= atlasSize) { info.u0 = atlasX_ / (float)ATLAS_SIZE;
break; // Atlas full info.v0 = atlasY_ / (float)ATLAS_SIZE;
info.u1 = (atlasX_ + w) / (float)ATLAS_SIZE;
info.v1 = (atlasY_ + h) / (float)ATLAS_SIZE;
info.xoff = face->glyph->bitmap_left;
info.yoff = -face->glyph->bitmap_top;
info.xadvance = face->glyph->advance.x / 64.0f;
info.width = w;
info.height = h;
atlasX_ += w + 1;
atlasRowHeight_ = std::max(atlasRowHeight_, h);
} }
// Render glyph to atlas simpleGlyphCache_[c] = info;
stbtt_MakeCodepointBitmap(info, &atlasPixels[y * atlasSize + x], w, h, atlasSize, scale, scale, c); glyphCache_[makeKey(c, 0.0f)] = info;
GlyphInfo glyph;
glyph.u0 = x / (float)atlasSize;
glyph.v0 = y / (float)atlasSize;
glyph.u1 = (x + w) / (float)atlasSize;
glyph.v1 = (y + h) / (float)atlasSize;
glyph.xoff = x0;
glyph.yoff = y0;
glyph.xadvance = advance * scale;
glyph.width = w;
glyph.height = h;
glyphCache_[c] = glyph;
x += w + 1;
rowHeight = std::max(rowHeight, h);
} }
// Convert single-channel to RGBA // Convert single-channel to RGBA and create texture ONCE
std::vector<unsigned char> rgbaPixels(atlasSize * atlasSize * 4); std::vector<unsigned char> rgbaPixels(ATLAS_SIZE * ATLAS_SIZE * 4);
for (int i = 0; i < atlasSize * atlasSize; ++i) { for (int i = 0; i < ATLAS_SIZE * ATLAS_SIZE; ++i) {
rgbaPixels[i * 4 + 0] = 255; rgbaPixels[i * 4 + 0] = 255;
rgbaPixels[i * 4 + 1] = 255; rgbaPixels[i * 4 + 1] = 255;
rgbaPixels[i * 4 + 2] = 255; rgbaPixels[i * 4 + 2] = 255;
rgbaPixels[i * 4 + 3] = atlasPixels[i]; rgbaPixels[i * 4 + 3] = atlasPixels_[i];
} }
textureId_ = SDL2Renderer::getInstance().createTexture(atlasSize, atlasSize, rgbaPixels.data()); textureId_ = SDL2Renderer::getInstance().createTexture(ATLAS_SIZE, ATLAS_SIZE, rgbaPixels.data());
// Debug: Check if any glyph pixels were actually rendered std::cout << "FontAtlas: created " << ATLAS_SIZE << "x" << ATLAS_SIZE
int nonZeroPixels = 0; << " atlas with " << simpleGlyphCache_.size() << " glyphs, textureId=" << textureId_ << std::endl;
for (int i = 0; i < atlasSize * atlasSize; ++i) {
if (atlasPixels[i] > 0) nonZeroPixels++;
}
std::cout << "FontAtlas: created " << atlasSize << "x" << atlasSize
<< " atlas with " << glyphCache_.size() << " glyphs, "
<< nonZeroPixels << " non-zero pixels, textureId=" << textureId_ << std::endl;
return true; return true;
} }
bool FontAtlas::getGlyph(uint32_t codepoint, GlyphInfo& info) const { // Legacy interface using raw font data - creates temporary FreeType objects
auto it = glyphCache_.find(codepoint); // Note: This path doesn't support on-demand stroked glyph loading since FreeType
// objects are freed after initialization. Use Font-based load() for full features.
bool FontAtlas::load(const unsigned char* fontData, size_t dataSize, float fontSize) {
fontSize_ = fontSize;
// Initialize FreeType for this atlas
FT_Library library;
if (FT_Init_FreeType(&library) != 0) {
std::cerr << "FontAtlas: Failed to initialize FreeType" << std::endl;
return false;
}
FT_Face face;
if (FT_New_Memory_Face(library, fontData, dataSize, 0, &face) != 0) {
std::cerr << "FontAtlas: Failed to create FreeType face" << std::endl;
FT_Done_FreeType(library);
return false;
}
// Set pixel size
FT_Set_Pixel_Sizes(face, 0, static_cast<FT_UInt>(fontSize));
// Get font metrics
ascent_ = face->size->metrics.ascender / 64.0f;
descent_ = face->size->metrics.descender / 64.0f;
lineHeight_ = face->size->metrics.height / 64.0f;
// Create glyph atlas - pre-load all ASCII glyphs
atlasPixels_.resize(ATLAS_SIZE * ATLAS_SIZE, 0);
atlasX_ = 1;
atlasY_ = 1;
atlasRowHeight_ = 0;
for (uint32_t c = 32; c < 128; ++c) {
FT_UInt glyphIndex = FT_Get_Char_Index(face, c);
if (glyphIndex == 0) continue;
if (FT_Load_Glyph(face, glyphIndex, FT_LOAD_RENDER) != 0) continue;
FT_Bitmap& bitmap = face->glyph->bitmap;
int w = bitmap.width;
int h = bitmap.rows;
GlyphInfo glyph;
if (w == 0 || h == 0) {
glyph.u0 = glyph.v0 = glyph.u1 = glyph.v1 = 0;
glyph.xoff = 0;
glyph.yoff = 0;
glyph.xadvance = face->glyph->advance.x / 64.0f;
glyph.width = 0;
glyph.height = 0;
} else {
if (atlasX_ + w + 1 >= ATLAS_SIZE) {
atlasX_ = 1;
atlasY_ += atlasRowHeight_ + 1;
atlasRowHeight_ = 0;
}
if (atlasY_ + h + 1 >= ATLAS_SIZE) break;
for (int y = 0; y < h; ++y) {
for (int x = 0; x < w; ++x) {
atlasPixels_[(atlasY_ + y) * ATLAS_SIZE + atlasX_ + x] = bitmap.buffer[y * bitmap.pitch + x];
}
}
glyph.u0 = atlasX_ / (float)ATLAS_SIZE;
glyph.v0 = atlasY_ / (float)ATLAS_SIZE;
glyph.u1 = (atlasX_ + w) / (float)ATLAS_SIZE;
glyph.v1 = (atlasY_ + h) / (float)ATLAS_SIZE;
glyph.xoff = face->glyph->bitmap_left;
glyph.yoff = -face->glyph->bitmap_top;
glyph.xadvance = face->glyph->advance.x / 64.0f;
glyph.width = w;
glyph.height = h;
atlasX_ += w + 1;
atlasRowHeight_ = std::max(atlasRowHeight_, h);
}
simpleGlyphCache_[c] = glyph;
glyphCache_[makeKey(c, 0.0f)] = glyph;
}
// Clean up temporary FreeType objects
FT_Done_Face(face);
FT_Done_FreeType(library);
// Convert to RGBA and create texture ONCE
std::vector<unsigned char> rgbaPixels(ATLAS_SIZE * ATLAS_SIZE * 4);
for (int i = 0; i < ATLAS_SIZE * ATLAS_SIZE; ++i) {
rgbaPixels[i * 4 + 0] = 255;
rgbaPixels[i * 4 + 1] = 255;
rgbaPixels[i * 4 + 2] = 255;
rgbaPixels[i * 4 + 3] = atlasPixels_[i];
}
textureId_ = SDL2Renderer::getInstance().createTexture(ATLAS_SIZE, ATLAS_SIZE, rgbaPixels.data());
std::cout << "FontAtlas: created " << ATLAS_SIZE << "x" << ATLAS_SIZE
<< " atlas with " << simpleGlyphCache_.size() << " glyphs, textureId=" << textureId_ << std::endl;
return true;
}
bool FontAtlas::loadGlyph(uint32_t codepoint, float outlineThickness) {
if (!font_ || !font_->getFTFace()) return false;
FT_Face face = static_cast<FT_Face>(font_->getFTFace());
// Make sure pixel size is set
FT_Set_Pixel_Sizes(face, 0, static_cast<FT_UInt>(fontSize_));
FT_UInt glyphIndex = FT_Get_Char_Index(face, codepoint);
if (glyphIndex == 0) return false;
// Load glyph without rendering (we may need to stroke it first)
if (FT_Load_Glyph(face, glyphIndex, FT_LOAD_DEFAULT) != 0) return false;
FT_Glyph glyph;
if (FT_Get_Glyph(face->glyph, &glyph) != 0) return false;
// Apply stroking if outline thickness > 0
if (outlineThickness > 0.0f && glyph->format == FT_GLYPH_FORMAT_OUTLINE) {
FT_Stroker stroker = static_cast<FT_Stroker>(font_->getFTStroker());
if (stroker) {
// Set stroker parameters (thickness is in 26.6 fixed-point)
FT_Stroker_Set(stroker,
static_cast<FT_Fixed>(outlineThickness * 64.0f),
FT_STROKER_LINECAP_ROUND,
FT_STROKER_LINEJOIN_ROUND,
0);
// Stroke the glyph outline (replaces outline with stroked version)
FT_Glyph_Stroke(&glyph, stroker, 1);
}
}
// Convert to bitmap
if (FT_Glyph_To_Bitmap(&glyph, FT_RENDER_MODE_NORMAL, nullptr, 1) != 0) {
FT_Done_Glyph(glyph);
return false;
}
FT_BitmapGlyph bitmapGlyph = reinterpret_cast<FT_BitmapGlyph>(glyph);
FT_Bitmap& bitmap = bitmapGlyph->bitmap;
int w = bitmap.width;
int h = bitmap.rows;
GlyphInfo info;
if (w == 0 || h == 0) {
// Space or invisible character
info.u0 = info.v0 = info.u1 = info.v1 = 0;
info.xoff = 0;
info.yoff = 0;
info.xadvance = face->glyph->advance.x / 64.0f;
info.width = 0;
info.height = 0;
} else {
// Check if we need to move to next row
if (atlasX_ + w + 1 >= ATLAS_SIZE) {
atlasX_ = 1;
atlasY_ += atlasRowHeight_ + 1;
atlasRowHeight_ = 0;
}
if (atlasY_ + h + 1 >= ATLAS_SIZE) {
std::cerr << "FontAtlas: Atlas full" << std::endl;
FT_Done_Glyph(glyph);
return false;
}
// Copy bitmap to atlas pixel buffer
for (int y = 0; y < h; ++y) {
for (int x = 0; x < w; ++x) {
atlasPixels_[(atlasY_ + y) * ATLAS_SIZE + atlasX_ + x] = bitmap.buffer[y * bitmap.pitch + x];
}
}
info.u0 = atlasX_ / (float)ATLAS_SIZE;
info.v0 = atlasY_ / (float)ATLAS_SIZE;
info.u1 = (atlasX_ + w) / (float)ATLAS_SIZE;
info.v1 = (atlasY_ + h) / (float)ATLAS_SIZE;
info.xoff = bitmapGlyph->left;
info.yoff = -bitmapGlyph->top; // FreeType uses bottom-up
info.xadvance = face->glyph->advance.x / 64.0f;
info.width = w;
info.height = h;
// Update ONLY the region of the texture that changed (not the whole atlas!)
if (textureId_) {
// Convert just this glyph region to RGBA
std::vector<unsigned char> glyphRGBA(w * h * 4);
for (int y = 0; y < h; ++y) {
for (int x = 0; x < w; ++x) {
int srcIdx = (atlasY_ + y) * ATLAS_SIZE + atlasX_ + x;
int dstIdx = (y * w + x) * 4;
glyphRGBA[dstIdx + 0] = 255;
glyphRGBA[dstIdx + 1] = 255;
glyphRGBA[dstIdx + 2] = 255;
glyphRGBA[dstIdx + 3] = atlasPixels_[srcIdx];
}
}
// Use glTexSubImage2D to update just the glyph region
SDL2Renderer::getInstance().updateTexture(textureId_, atlasX_, atlasY_, w, h, glyphRGBA.data());
}
atlasX_ += w + 1;
atlasRowHeight_ = std::max(atlasRowHeight_, h);
}
// Store in appropriate cache
if (outlineThickness == 0.0f) {
simpleGlyphCache_[codepoint] = info;
}
uint64_t key = makeKey(codepoint, outlineThickness);
glyphCache_[key] = info;
FT_Done_Glyph(glyph);
return true;
}
bool FontAtlas::getGlyph(uint32_t codepoint, float outlineThickness, GlyphInfo& info) {
uint64_t key = makeKey(codepoint, outlineThickness);
auto it = glyphCache_.find(key);
if (it != glyphCache_.end()) { if (it != glyphCache_.end()) {
info = it->second; info = it->second;
return true; return true;
} }
// Try to load the glyph on-demand
if (loadGlyph(codepoint, outlineThickness)) {
it = glyphCache_.find(key);
if (it != glyphCache_.end()) {
info = it->second;
return true;
}
}
return false;
}
bool FontAtlas::getGlyph(uint32_t codepoint, GlyphInfo& info) const {
auto it = simpleGlyphCache_.find(codepoint);
if (it != simpleGlyphCache_.end()) {
info = it->second;
return true;
}
return false; return false;
} }

View file

@ -162,7 +162,10 @@ public:
FontAtlas(const FontAtlas&) = delete; FontAtlas(const FontAtlas&) = delete;
FontAtlas& operator=(const FontAtlas&) = delete; FontAtlas& operator=(const FontAtlas&) = delete;
// Load font data // Load font using Font's FreeType handles
bool load(const class Font* font, float fontSize);
// Legacy interface for backwards compatibility (uses global FT library)
bool load(const unsigned char* fontData, size_t dataSize, float fontSize); bool load(const unsigned char* fontData, size_t dataSize, float fontSize);
// Get texture atlas // Get texture atlas
@ -176,6 +179,10 @@ public:
float width, height; // Glyph dimensions in pixels float width, height; // Glyph dimensions in pixels
}; };
// Get glyph with optional outline thickness (0 = no outline)
bool getGlyph(uint32_t codepoint, float outlineThickness, GlyphInfo& info);
// Legacy interface (no outline)
bool getGlyph(uint32_t codepoint, GlyphInfo& info) const; bool getGlyph(uint32_t codepoint, GlyphInfo& info) const;
// Get font metrics // Get font metrics
@ -190,11 +197,28 @@ private:
float descent_ = 0; float descent_ = 0;
float lineHeight_ = 0; float lineHeight_ = 0;
// Glyph cache - maps codepoint to glyph info // FreeType handles (stored for on-demand glyph loading)
std::unordered_map<uint32_t, GlyphInfo> glyphCache_; const class Font* font_ = nullptr;
// stb_truetype font info (opaque pointer to avoid header inclusion) // Atlas packing state for on-demand glyph loading
void* stbFontInfo_ = nullptr; static const int ATLAS_SIZE = 1024;
std::vector<unsigned char> atlasPixels_;
int atlasX_ = 1;
int atlasY_ = 1;
int atlasRowHeight_ = 0;
// Glyph cache - maps (codepoint, outlineThickness) to glyph info
// Key: (outlineThickness bits << 32) | codepoint
std::unordered_map<uint64_t, GlyphInfo> glyphCache_;
// Simple glyph cache for backwards compatibility (no outline)
std::unordered_map<uint32_t, GlyphInfo> simpleGlyphCache_;
// Helper to make cache key
static uint64_t makeKey(uint32_t codepoint, float outlineThickness);
// Load a glyph on-demand with optional stroking
bool loadGlyph(uint32_t codepoint, float outlineThickness);
}; };
} // namespace sf } // namespace sf

View file

@ -722,16 +722,22 @@ protected:
// ============================================================================= // =============================================================================
class Font { class Font {
// SDL2-specific: font data for stb_truetype // SDL2-specific: font data for FreeType
std::vector<unsigned char> fontData_; std::vector<unsigned char> fontData_;
bool loaded_ = false; bool loaded_ = false;
// FreeType handles (void* to avoid header dependency in .h)
void* ftLibrary_ = nullptr; // FT_Library
void* ftFace_ = nullptr; // FT_Face
void* ftStroker_ = nullptr; // FT_Stroker
public: public:
struct Info { struct Info {
std::string family; std::string family;
}; };
Font() = default; Font() = default;
~Font(); // Destructor for FreeType cleanup - implemented in SDL2Renderer.cpp
bool loadFromFile(const std::string& filename); // Implemented in SDL2Renderer.cpp bool loadFromFile(const std::string& filename); // Implemented in SDL2Renderer.cpp
bool loadFromMemory(const void* data, size_t sizeInBytes); // Implemented in SDL2Renderer.cpp bool loadFromMemory(const void* data, size_t sizeInBytes); // Implemented in SDL2Renderer.cpp
const Info& getInfo() const { static Info info; return info; } const Info& getInfo() const { static Info info; return info; }
@ -740,6 +746,11 @@ public:
const unsigned char* getData() const { return fontData_.data(); } const unsigned char* getData() const { return fontData_.data(); }
size_t getDataSize() const { return fontData_.size(); } size_t getDataSize() const { return fontData_.size(); }
bool isLoaded() const { return loaded_; } bool isLoaded() const { return loaded_; }
// FreeType accessors
void* getFTFace() const { return ftFace_; }
void* getFTStroker() const { return ftStroker_; }
void* getFTLibrary() const { return ftLibrary_; }
}; };
class Text : public Drawable, public Transformable { class Text : public Drawable, public Transformable {

View file

@ -43,7 +43,7 @@
min-width: 300px; min-width: 300px;
} }
#canvas { #canvas {
border: 2px solid #e94560; /* border: 2px solid #e94560; -- disabled per Emscripten docs, causes alignment issues */
background: #000; background: #000;
display: block; display: block;
max-width: 100%; max-width: 100%;
@ -56,7 +56,6 @@
outline: none; outline: none;
} }
#canvas:focus { #canvas:focus {
border-color: #4ecca3;
box-shadow: 0 0 10px rgba(78, 204, 163, 0.5); box-shadow: 0 0 10px rgba(78, 204, 163, 0.5);
} }
.repl-panel { .repl-panel {