feat: add text rendering with SDF's
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user