feat: add text rendering with SDF's

This commit is contained in:
2026-10-17 16:00:51 +02:00
parent 7dd5ee7903
commit 67c685a158
12 changed files with 746 additions and 51 deletions
@@ -3,6 +3,8 @@
#include <algorithm>
#include <cmath>
#include <destrum/Graphics/Resources/Font.h>
void Renderer2D::DrawRect(const glm::vec2& topLeft, const glm::vec2& size, const glm::vec4& color)
{
DrawRect(topLeft, size, ShapeStyle{.color = color});
@@ -104,6 +106,55 @@ void Renderer2D::DrawLine(
});
}
void Renderer2D::DrawText(
const Font& font,
std::string_view text,
const glm::vec2& topLeft,
float size,
const glm::vec4& color)
{
DrawText(font, text, topLeft, size, TextStyle{.color = color});
}
void Renderer2D::DrawText(
const Font& font,
std::string_view text,
const glm::vec2& topLeft,
float size,
const TextStyle& style)
{
if (font.GetAtlas() == NULL_IMAGE_ID || size <= 0.0f) {
return;
}
const float scale = size / font.GetPixelSize();
// FreeType maps the spread to the 128 values on either side of the edge
const float distanceScale = static_cast<float>(Font::SdfSpread) * scale * (255.0f / 128.0f);
const float outline = std::clamp(style.outline, 0.0f, static_cast<float>(Font::SdfSpread) * scale - 1.0f);
font.Layout(text, topLeft, size, [&](const PlacedGlyph& placed) {
submit(
Shape2DType::Text,
placed.topLeft + placed.size * 0.5f,
placed.size * 0.5f,
ShapeStyle{
.color = style.color,
.outline = outline,
.texture = {
.id = font.GetAtlas(),
.uvMin = placed.glyph->uvMin,
.uvMax = placed.glyph->uvMax,
.filter = TextureFilter::Linear,
},
});
Shape2D& shape = shapes.back();
shape.p0 = {distanceScale, 0.0f};
shape.p1 = {style.outlineColor.x, style.outlineColor.y};
shape.p2 = {style.outlineColor.z, style.outlineColor.w};
});
}
void Renderer2D::Clear()
{
shapes.clear();
+338
View File
@@ -0,0 +1,338 @@
#include <destrum/Graphics/Resources/Font.h>
#include <algorithm>
#include <cstring>
#include <fstream>
#include <iterator>
#include <stdexcept>
#include <string>
#include <ft2build.h>
#include FT_FREETYPE_H
#include FT_MODULE_H
#include <spdlog/spdlog.h>
#include <destrum/Graphics/GfxDevice.h>
#include <destrum/Graphics/RenderResources.h>
namespace {
constexpr std::uint32_t AtlasWidth = 1024;
constexpr std::uint32_t GlyphPadding = 2;
constexpr int TabWidthInSpaces = 4;
struct CodepointRange {
char32_t first;
char32_t last;
};
// Printable ASCII and Latin-1
constexpr CodepointRange LoadedRanges[] = {
{0x20, 0x7E},
{0xA0, 0xFF},
};
struct FreeTypeLibrary {
FT_Library handle{nullptr};
~FreeTypeLibrary() { if (handle) FT_Done_FreeType(handle); }
};
struct FreeTypeFace {
FT_Face handle{nullptr};
~FreeTypeFace() { if (handle) FT_Done_Face(handle); }
};
struct RasterizedGlyph {
char32_t codepoint;
FontGlyph glyph;
std::uint32_t width;
std::uint32_t height;
std::vector<std::uint8_t> pixels;
};
std::uint64_t kerningKey(char32_t left, char32_t right)
{
return (static_cast<std::uint64_t>(left) << 32) | static_cast<std::uint64_t>(right);
}
std::vector<FT_Byte> readFile(const std::filesystem::path& path)
{
std::ifstream file(path, std::ios::binary);
if (!file) {
throw std::runtime_error("Font: failed to open " + path.string());
}
return {std::istreambuf_iterator<char>(file), std::istreambuf_iterator<char>()};
}
void check(FT_Error error, const char* what, const std::filesystem::path& path)
{
if (error != 0) {
const char* message = FT_Error_String(error);
throw std::runtime_error(
std::string("Font: ") + what + " failed for " + path.string() + ": " +
(message ? message : std::to_string(error)));
}
}
}
Font Font::LoadFromFile(const std::filesystem::path& path, float pixelSize)
{
if (pixelSize <= 0.0f) {
throw std::invalid_argument("Font: pixel size must be positive");
}
const std::vector<FT_Byte> fileData = readFile(path);
FreeTypeLibrary library;
check(FT_Init_FreeType(&library.handle), "FT_Init_FreeType", path);
FT_Int spread = SdfSpread;
FT_Property_Set(library.handle, "sdf", "spread", &spread);
FT_Property_Set(library.handle, "bsdf", "spread", &spread);
FreeTypeFace face;
check(
FT_New_Memory_Face(library.handle, fileData.data(), static_cast<FT_Long>(fileData.size()), 0, &face.handle),
"FT_New_Memory_Face",
path);
check(FT_Set_Pixel_Sizes(face.handle, 0, static_cast<FT_UInt>(pixelSize)), "FT_Set_Pixel_Sizes", path);
Font font;
font.pixelSize = static_cast<float>(static_cast<FT_UInt>(pixelSize));
font.ascender = static_cast<float>(face.handle->size->metrics.ascender) / 64.0f;
font.lineHeight = static_cast<float>(face.handle->size->metrics.height) / 64.0f;
std::vector<RasterizedGlyph> rasterized;
for (const auto& range : LoadedRanges) {
for (char32_t codepoint = range.first; codepoint <= range.last; ++codepoint) {
if (FT_Get_Char_Index(face.handle, codepoint) == 0) {
continue;
}
if (FT_Load_Char(face.handle, codepoint, FT_LOAD_DEFAULT | FT_LOAD_NO_HINTING) != 0) {
spdlog::warn("Font: failed to load U+{:04X} from {}", static_cast<std::uint32_t>(codepoint), path.string());
continue;
}
FT_GlyphSlot slot = face.handle->glyph;
RasterizedGlyph entry{
.codepoint = codepoint,
.glyph = {.advance = static_cast<float>(slot->advance.x) / 64.0f},
.width = 0,
.height = 0,
.pixels = {},
};
const bool hasOutline = slot->format == FT_GLYPH_FORMAT_OUTLINE && slot->outline.n_contours > 0;
if (hasOutline && FT_Render_Glyph(slot, FT_RENDER_MODE_SDF) == 0) {
const FT_Bitmap& bitmap = slot->bitmap;
entry.width = bitmap.width;
entry.height = bitmap.rows;
entry.pixels.resize(std::size_t(entry.width) * entry.height);
for (std::uint32_t row = 0; row < entry.height; ++row) {
std::memcpy(
entry.pixels.data() + std::size_t(row) * entry.width,
bitmap.buffer + static_cast<std::ptrdiff_t>(row) * bitmap.pitch,
entry.width);
}
entry.glyph.offset = {
static_cast<float>(slot->bitmap_left),
-static_cast<float>(slot->bitmap_top),
};
entry.glyph.size = {static_cast<float>(entry.width), static_cast<float>(entry.height)};
}
rasterized.push_back(std::move(entry));
}
}
if (rasterized.empty()) {
throw std::runtime_error("Font: no glyphs could be loaded from " + path.string());
}
// Shelf packing, tallest first so the shelves stay tight
std::vector<RasterizedGlyph*> packOrder;
packOrder.reserve(rasterized.size());
for (auto& entry : rasterized) {
packOrder.push_back(&entry);
}
std::ranges::sort(packOrder, [](const RasterizedGlyph* a, const RasterizedGlyph* b) {
return a->height > b->height;
});
struct Placement {
std::uint32_t x;
std::uint32_t y;
};
std::unordered_map<const RasterizedGlyph*, Placement> placements;
std::uint32_t cursorX = GlyphPadding;
std::uint32_t cursorY = GlyphPadding;
std::uint32_t shelfHeight = 0;
for (const RasterizedGlyph* entry : packOrder) {
if (entry->width == 0 || entry->height == 0) {
continue;
}
if (entry->width + 2 * GlyphPadding > AtlasWidth) {
throw std::runtime_error("Font: glyph too large for the atlas in " + path.string());
}
if (cursorX + entry->width + GlyphPadding > AtlasWidth) {
cursorX = GlyphPadding;
cursorY += shelfHeight + GlyphPadding;
shelfHeight = 0;
}
placements[entry] = {cursorX, cursorY};
cursorX += entry->width + GlyphPadding;
shelfHeight = std::max(shelfHeight, entry->height);
}
const std::uint32_t atlasHeight = std::max(cursorY + shelfHeight + GlyphPadding, 1u);
font.atlasSize = {AtlasWidth, atlasHeight};
font.atlasPixels.assign(std::size_t(AtlasWidth) * atlasHeight, 0);
const glm::vec2 atlasExtent{font.atlasSize};
for (auto& entry : rasterized) {
if (const auto it = placements.find(&entry); it != placements.end()) {
const auto [x, y] = it->second;
for (std::uint32_t row = 0; row < entry.height; ++row) {
std::memcpy(
font.atlasPixels.data() + std::size_t(y + row) * AtlasWidth + x,
entry.pixels.data() + std::size_t(row) * entry.width,
entry.width);
}
entry.glyph.uvMin = glm::vec2{static_cast<float>(x), static_cast<float>(y)} / atlasExtent;
entry.glyph.uvMax = glm::vec2{static_cast<float>(x + entry.width), static_cast<float>(y + entry.height)} /
atlasExtent;
}
font.glyphs.emplace(entry.codepoint, entry.glyph);
}
if (const auto space = font.glyphs.find(U' '); space != font.glyphs.end()) {
FontGlyph tab = space->second;
tab.advance *= TabWidthInSpaces;
font.glyphs.emplace(U'\t', tab);
}
if (FT_HAS_KERNING(face.handle)) {
for (const auto& left : rasterized) {
const FT_UInt leftIndex = FT_Get_Char_Index(face.handle, left.codepoint);
for (const auto& right : rasterized) {
const FT_UInt rightIndex = FT_Get_Char_Index(face.handle, right.codepoint);
FT_Vector delta{};
if (FT_Get_Kerning(face.handle, leftIndex, rightIndex, FT_KERNING_UNFITTED, &delta) == 0 &&
delta.x != 0) {
font.kerning.emplace(
kerningKey(left.codepoint, right.codepoint),
static_cast<float>(delta.x) / 64.0f);
}
}
}
}
return font;
}
void Font::Upload(GfxDevice& gfxDevice, RenderResources& resources)
{
if (atlas != NULL_IMAGE_ID) {
return;
}
if (atlasPixels.empty()) {
throw std::runtime_error("Font: nothing to upload, load the font first");
}
atlas = resources.createImage(
gfxDevice,
{
.format = VK_FORMAT_R8_UNORM,
.usage = VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT,
.extent = VkExtent3D{atlasSize.x, atlasSize.y, 1},
},
"font atlas",
atlasPixels.data());
atlasPixels = {};
}
const FontGlyph* Font::GetGlyph(char32_t codepoint) const
{
if (const auto it = glyphs.find(codepoint); it != glyphs.end()) {
return &it->second;
}
if (const auto fallback = glyphs.find(U'?'); fallback != glyphs.end()) {
return &fallback->second;
}
return nullptr;
}
float Font::GetKerning(char32_t left, char32_t right) const
{
const auto it = kerning.find(kerningKey(left, right));
return it != kerning.end() ? it->second : 0.0f;
}
glm::vec2 Font::MeasureText(std::string_view text, float size) const
{
const float scale = size / pixelSize;
float lineWidth = 0.0f;
float maxWidth = 0.0f;
int lines = text.empty() ? 0 : 1;
char32_t previous = 0;
for (std::size_t i = 0; i < text.size();) {
const char32_t codepoint = DecodeUtf8(text, i);
if (codepoint == U'\n') {
maxWidth = std::max(maxWidth, lineWidth);
lineWidth = 0.0f;
previous = 0;
++lines;
continue;
}
const FontGlyph* glyph = GetGlyph(codepoint);
if (!glyph) {
continue;
}
if (previous != 0) {
lineWidth += GetKerning(previous, codepoint) * scale;
}
previous = codepoint;
lineWidth += glyph->advance * scale;
}
maxWidth = std::max(maxWidth, lineWidth);
return {maxWidth, static_cast<float>(lines) * lineHeight * scale};
}
char32_t Font::DecodeUtf8(std::string_view text, std::size_t& index)
{
constexpr char32_t Replacement = 0xFFFD;
const auto byte = [&](std::size_t i) { return static_cast<std::uint8_t>(text[i]); };
const std::uint8_t lead = byte(index++);
if (lead < 0x80) {
return lead;
}
int continuationBytes;
char32_t codepoint;
if ((lead & 0xE0) == 0xC0) {
continuationBytes = 1;
codepoint = lead & 0x1F;
} else if ((lead & 0xF0) == 0xE0) {
continuationBytes = 2;
codepoint = lead & 0x0F;
} else if ((lead & 0xF8) == 0xF0) {
continuationBytes = 3;
codepoint = lead & 0x07;
} else {
return Replacement;
}
for (int i = 0; i < continuationBytes; ++i) {
if (index >= text.size() || (byte(index) & 0xC0) != 0x80) {
return Replacement;
}
codepoint = (codepoint << 6) | (byte(index++) & 0x3F);
}
return codepoint;
}