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

@ -26,12 +26,16 @@
#include <GL/gl.h>
#endif
// stb libraries for image/font loading (from deps/stb/)
// stb_image for image loading (from deps/stb/)
#define STB_IMAGE_IMPLEMENTATION
#include <stb_image.h>
#define STB_TRUETYPE_IMPLEMENTATION
#include <stb_truetype.h>
// FreeType for font loading and text rendering with proper outline support
#include <ft2build.h>
#include FT_FREETYPE_H
#include FT_GLYPH_H
#include FT_STROKER_H
#include FT_OUTLINE_H
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) {
// Read file into memory first (FreeType needs persistent data)
FILE* file = fopen(filename.c_str(), "rb");
if (!file) {
std::cerr << "Font: Failed to open file: " << filename << std::endl;
return false;
}
@ -1222,13 +1243,81 @@ bool Font::loadFromFile(const std::string& filename) {
fread(fontData_.data(), 1, size, 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;
std::cout << "Font: Loaded " << filename << " with FreeType" << std::endl;
return true;
}
bool Font::loadFromMemory(const void* data, size_t sizeInBytes) {
fontData_.resize(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;
return true;
}
@ -1599,7 +1688,7 @@ void Sprite::draw(RenderTarget& target, RenderStates states) const {
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;
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);
if (it == s_fontAtlasCache.end()) {
FontAtlas atlas;
if (!atlas.load(font_->getData(), font_->getDataSize(), static_cast<float>(characterSize_))) {
return; // Failed to create 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_))) {
return; // Failed to create atlas
}
}
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();
// Helper lambda to build glyph geometry with a given color and offset
auto buildGlyphs = [&](const Color& color, float offsetX, float offsetY,
// Helper lambda to build glyph geometry with a given color and outline thickness
auto buildGlyphs = [&](const Color& color, float outlineThickness,
std::vector<float>& verts, std::vector<float>& uvs, std::vector<float>& cols) {
float x = 0;
float y = atlas.getAscent();
@ -1640,15 +1736,34 @@ void Text::draw(RenderTarget& target, RenderStates states) const {
}
FontAtlas::GlyphInfo glyph;
if (!atlas.getGlyph(static_cast<uint32_t>(c), glyph)) {
if (!atlas.getGlyph(' ', glyph)) {
continue;
}
// Use stroked glyph lookup for outlines, regular for fill
bool found = false;
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
float x0 = x + glyph.xoff + offsetX;
float y0 = y + glyph.yoff + offsetY;
if (!found) {
// Try space as fallback
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 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) {
std::vector<float> outlineVerts, outlineUVs, outlineCols;
// Draw at 8 positions around each glyph for outline effect
float t = outlineThickness_;
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);
}
// Use FreeType stroker for proper vector-based outlines
buildGlyphs(outlineColor_, outlineThickness_, outlineVerts, outlineUVs, outlineCols);
if (!outlineVerts.empty()) {
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;
buildGlyphs(fillColor_, 0, 0, vertices, texcoords, colors);
buildGlyphs(fillColor_, 0.0f, vertices, texcoords, colors);
if (!vertices.empty()) {
SDL2Renderer::getInstance().drawTriangles(
@ -1726,8 +1832,15 @@ FloatRect Text::getLocalBounds() const {
auto it = s_fontAtlasCache.find(key);
if (it == s_fontAtlasCache.end()) {
FontAtlas atlas;
if (!atlas.load(font_->getData(), font_->getDataSize(), static_cast<float>(characterSize_))) {
return FloatRect(0, 0, 0, 0);
// 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_))) {
return FloatRect(0, 0, 0, 0);
}
}
it = s_fontAtlasCache.emplace(key, std::move(atlas)).first;
}
@ -1863,7 +1976,7 @@ bool Shader::isAvailable() {
}
// =============================================================================
// FontAtlas Implementation
// FontAtlas Implementation (FreeType-based)
// =============================================================================
FontAtlas::FontAtlas() = default;
@ -1874,12 +1987,17 @@ FontAtlas::FontAtlas(FontAtlas&& other) noexcept
, ascent_(other.ascent_)
, descent_(other.descent_)
, 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_))
, stbFontInfo_(other.stbFontInfo_)
, simpleGlyphCache_(std::move(other.simpleGlyphCache_))
{
// Clear source to prevent double-deletion
other.textureId_ = 0;
other.stbFontInfo_ = nullptr;
other.font_ = nullptr;
}
FontAtlas& FontAtlas::operator=(FontAtlas&& other) noexcept {
@ -1888,9 +2006,6 @@ FontAtlas& FontAtlas::operator=(FontAtlas&& other) noexcept {
if (textureId_) {
SDL2Renderer::getInstance().deleteTexture(textureId_);
}
if (stbFontInfo_) {
delete static_cast<stbtt_fontinfo*>(stbFontInfo_);
}
// Transfer ownership
textureId_ = other.textureId_;
@ -1898,12 +2013,17 @@ FontAtlas& FontAtlas::operator=(FontAtlas&& other) noexcept {
ascent_ = other.ascent_;
descent_ = other.descent_;
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_);
stbFontInfo_ = other.stbFontInfo_;
simpleGlyphCache_ = std::move(other.simpleGlyphCache_);
// Clear source to prevent double-deletion
other.textureId_ = 0;
other.stbFontInfo_ = nullptr;
other.font_ = nullptr;
}
return *this;
}
@ -1912,107 +2032,361 @@ FontAtlas::~FontAtlas() {
if (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) {
fontSize_ = fontSize;
uint64_t FontAtlas::makeKey(uint32_t codepoint, float outlineThickness) {
// 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();
if (!stbtt_InitFont(info, fontData, 0)) {
delete info;
bool FontAtlas::load(const Font* font, float fontSize) {
if (!font || !font->isLoaded() || !font->getFTFace()) {
std::cerr << "FontAtlas: Invalid font or font not loaded" << std::endl;
return false;
}
stbFontInfo_ = info;
font_ = font;
fontSize_ = fontSize;
// Get font metrics
int ascent, descent, lineGap;
stbtt_GetFontVMetrics(info, &ascent, &descent, &lineGap);
FT_Face face = static_cast<FT_Face>(font->getFTFace());
float scale = stbtt_ScaleForPixelHeight(info, fontSize);
ascent_ = ascent * scale;
descent_ = descent * scale;
lineHeight_ = (ascent - descent + lineGap) * scale;
// Create glyph atlas (simple ASCII for now)
const int atlasSize = 512;
std::vector<unsigned char> atlasPixels(atlasSize * atlasSize, 0);
int x = 1, y = 1;
int rowHeight = 0;
for (uint32_t c = 32; c < 128; ++c) {
int advance, lsb;
stbtt_GetCodepointHMetrics(info, c, &advance, &lsb);
int x0, y0, x1, y1;
stbtt_GetCodepointBitmapBox(info, c, scale, scale, &x0, &y0, &x1, &y1);
int w = x1 - x0;
int h = y1 - y0;
if (x + w + 1 >= atlasSize) {
x = 1;
y += rowHeight + 1;
rowHeight = 0;
}
if (y + h + 1 >= atlasSize) {
break; // Atlas full
}
// Render glyph to atlas
stbtt_MakeCodepointBitmap(info, &atlasPixels[y * atlasSize + x], w, h, atlasSize, scale, scale, c);
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);
// Set pixel size
if (FT_Set_Pixel_Sizes(face, 0, static_cast<FT_UInt>(fontSize)) != 0) {
std::cerr << "FontAtlas: Failed to set pixel size" << std::endl;
return false;
}
// Convert single-channel to RGBA
std::vector<unsigned char> rgbaPixels(atlasSize * atlasSize * 4);
for (int i = 0; i < atlasSize * atlasSize; ++i) {
// Get font metrics (in 26.6 fixed-point format)
ascent_ = face->size->metrics.ascender / 64.0f;
descent_ = face->size->metrics.descender / 64.0f;
lineHeight_ = face->size->metrics.height / 64.0f;
// Initialize atlas
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) {
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 info;
if (w == 0 || h == 0) {
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 {
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];
}
}
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 = 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);
}
simpleGlyphCache_[c] = info;
glyphCache_[makeKey(c, 0.0f)] = info;
}
// Convert single-channel 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];
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
int nonZeroPixels = 0;
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;
std::cout << "FontAtlas: created " << ATLAS_SIZE << "x" << ATLAS_SIZE
<< " atlas with " << simpleGlyphCache_.size() << " glyphs, textureId=" << textureId_ << std::endl;
return true;
}
bool FontAtlas::getGlyph(uint32_t codepoint, GlyphInfo& info) const {
auto it = glyphCache_.find(codepoint);
// Legacy interface using raw font data - creates temporary FreeType objects
// 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()) {
info = it->second;
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;
}

View file

@ -162,7 +162,10 @@ public:
FontAtlas(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);
// Get texture atlas
@ -176,6 +179,10 @@ public:
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;
// Get font metrics
@ -190,11 +197,28 @@ private:
float descent_ = 0;
float lineHeight_ = 0;
// Glyph cache - maps codepoint to glyph info
std::unordered_map<uint32_t, GlyphInfo> glyphCache_;
// FreeType handles (stored for on-demand glyph loading)
const class Font* font_ = nullptr;
// stb_truetype font info (opaque pointer to avoid header inclusion)
void* stbFontInfo_ = nullptr;
// Atlas packing state for on-demand glyph loading
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

View file

@ -722,16 +722,22 @@ protected:
// =============================================================================
class Font {
// SDL2-specific: font data for stb_truetype
// SDL2-specific: font data for FreeType
std::vector<unsigned char> fontData_;
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:
struct Info {
std::string family;
};
Font() = default;
~Font(); // Destructor for FreeType cleanup - 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
const Info& getInfo() const { static Info info; return info; }
@ -740,6 +746,11 @@ public:
const unsigned char* getData() const { return fontData_.data(); }
size_t getDataSize() const { return fontData_.size(); }
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 {

View file

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