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
+1
View File
@@ -78,6 +78,7 @@ set(SRC_FILES
"src/Graphics/Resources/GPUImage.cpp" "src/Graphics/Resources/GPUImage.cpp"
"src/Graphics/Resources/NBuffer.cpp" "src/Graphics/Resources/NBuffer.cpp"
"src/Graphics/Resources/Cubemap.cpp" "src/Graphics/Resources/Cubemap.cpp"
"src/Graphics/Resources/Font.cpp"
"src/Graphics/Resources/PrimitiveMeshes.cpp" "src/Graphics/Resources/PrimitiveMeshes.cpp"
"src/Graphics/Pipelines/MeshPipeline.cpp" "src/Graphics/Pipelines/MeshPipeline.cpp"
Binary file not shown.
+131 -39
View File
@@ -11,60 +11,152 @@ layout (location = 2) flat in uint inShapeIndex;
layout (location = 0) out vec4 outFragColor; layout (location = 0) out vec4 outFragColor;
float sdRoundedRect(vec2 p, vec2 halfSize, float radius) // FreeType stores the edge at 128, inside is brighter
const float TEXT_SDF_EDGE = 128.0 / 255.0;
float roundedRectDistance(vec2 position, vec2 halfSize, float cornerRadius)
{ {
radius = min(radius, min(halfSize.x, halfSize.y)); cornerRadius = min(cornerRadius, min(halfSize.x, halfSize.y));
vec2 q = abs(p) - halfSize + radius;
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - radius; vec2 cornerOffset = abs(position) - halfSize + cornerRadius;
float outsideDistance = length(max(cornerOffset, 0.0));
float insideDistance = min(max(cornerOffset.x, cornerOffset.y), 0.0);
return outsideDistance + insideDistance - cornerRadius;
} }
// Approximate, but exact on the boundary which is what the edge and outline need // Approximate, but exact on the boundary which is what the edge and outline need
float sdEllipse(vec2 p, vec2 radii) float ellipseDistance(vec2 position, vec2 radii)
{ {
radii = max(radii, vec2(1e-4)); radii = max(radii, vec2(1e-4));
float k0 = length(p / radii);
float k1 = length(p / (radii * radii)); float normalizedLength = length(position / radii);
return k0 < 1e-4 ? -min(radii.x, radii.y) : k0 * (k0 - 1.0) / k1; if (normalizedLength < 1e-4) {
return -min(radii.x, radii.y);
}
float gradientLength = length(position / (radii * radii));
return normalizedLength * (normalizedLength - 1.0) / gradientLength;
} }
float sdTriangle(vec2 p, vec2 p0, vec2 p1, vec2 p2) vec2 closestPointOnSegmentOffset(vec2 position, vec2 segmentStart, vec2 segmentEnd)
{ {
vec2 e0 = p1 - p0, e1 = p2 - p1, e2 = p0 - p2; vec2 segment = segmentEnd - segmentStart;
vec2 v0 = p - p0, v1 = p - p1, v2 = p - p2; vec2 toPosition = position - segmentStart;
vec2 pq0 = v0 - e0 * clamp(dot(v0, e0) / max(dot(e0, e0), 1e-8), 0.0, 1.0);
vec2 pq1 = v1 - e1 * clamp(dot(v1, e1) / max(dot(e1, e1), 1e-8), 0.0, 1.0); float along = clamp(
vec2 pq2 = v2 - e2 * clamp(dot(v2, e2) / max(dot(e2, e2), 1e-8), 0.0, 1.0); dot(toPosition, segment) / max(dot(segment, segment), 1e-8),
float winding = sign(e0.x * e2.y - e0.y * e2.x); 0.0, 1.0
vec2 d = min(min(vec2(dot(pq0, pq0), winding * (v0.x * e0.y - v0.y * e0.x)), );
vec2(dot(pq1, pq1), winding * (v1.x * e1.y - v1.y * e1.x))),
vec2(dot(pq2, pq2), winding * (v2.x * e2.y - v2.y * e2.x))); return toPosition - segment * along;
return -sqrt(d.x) * sign(d.y); }
float edgeSide(vec2 position, vec2 edgeStart, vec2 edgeEnd)
{
vec2 edge = edgeEnd - edgeStart;
vec2 toPosition = position - edgeStart;
return toPosition.x * edge.y - toPosition.y * edge.x;
}
float triangleDistance(vec2 position, vec2 a, vec2 b, vec2 c)
{
vec2 offsetAB = closestPointOnSegmentOffset(position, a, b);
vec2 offsetBC = closestPointOnSegmentOffset(position, b, c);
vec2 offsetCA = closestPointOnSegmentOffset(position, c, a);
float closestDistanceSquared = min(
dot(offsetAB, offsetAB),
min(dot(offsetBC, offsetBC), dot(offsetCA, offsetCA))
);
float winding = sign(edgeSide(c, a, b));
bool isInside =
winding * edgeSide(position, a, b) > 0.0 &&
winding * edgeSide(position, b, c) > 0.0 &&
winding * edgeSide(position, c, a) > 0.0;
float edgeDistance = sqrt(closestDistanceSquared);
return isInside ? -edgeDistance : edgeDistance;
}
float edgeCoverage(float signedDistance)
{
float pixelWidth = max(fwidth(signedDistance), 1e-4);
return clamp(0.5 - signedDistance / pixelWidth, 0.0, 1.0);
}
vec2 shapeUv(Shape2D shape)
{
return mix(
shape.uvRect.xy,
shape.uvRect.zw,
clamp(inQuadPos, 0.0, 1.0)
);
}
vec4 shadeText(Shape2D shape)
{
float sdf = sampleTexture2D(
shape.textureId, shape.samplerId, shapeUv(shape)
).r;
float signedDistance = (TEXT_SDF_EDGE - sdf) * shape.p0.x;
float fillCoverage = edgeCoverage(signedDistance);
if (shape.outline <= 0.0) {
return vec4(
shape.color.rgb,
shape.color.a * fillCoverage
);
}
vec4 outlineColor = vec4(shape.p1, shape.p2);
float outlineCoverage = edgeCoverage(signedDistance - shape.outline);
vec4 result = mix(outlineColor, shape.color, fillCoverage);
result.a *= outlineCoverage;
return result;
}
float shapeDistance(Shape2D shape)
{
if (shape.type == SHAPE_ELLIPSE) {
return ellipseDistance(inLocalPos, shape.halfSize);
}
if (shape.type == SHAPE_TRIANGLE) {
return triangleDistance(inLocalPos, shape.p0, shape.p1, shape.p2);
}
return roundedRectDistance(inLocalPos, shape.halfSize, shape.cornerRadius);
}
vec4 shadeShape(Shape2D shape)
{
float signedDistance = shapeDistance(shape);
if (shape.outline > 0.0) {
float halfOutline = shape.outline * 0.5;
signedDistance = abs(signedDistance + halfOutline) - halfOutline;
}
vec4 textureColor = sampleTexture2D(
shape.textureId, shape.samplerId, shapeUv(shape)
);
vec4 result = textureColor * shape.color;
result.a *= edgeCoverage(signedDistance);
return result;
} }
void main() void main()
{ {
Shape2D shape = pcs.shapeBuffer.shapes[inShapeIndex]; Shape2D shape = pcs.shapeBuffer.shapes[inShapeIndex];
float dist; if (shape.type == SHAPE_TEXT) {
if (shape.type == SHAPE_ELLIPSE) { outFragColor = shadeText(shape);
dist = sdEllipse(inLocalPos, shape.halfSize);
} else if (shape.type == SHAPE_TRIANGLE) {
dist = sdTriangle(inLocalPos, shape.p0, shape.p1, shape.p2);
} else { } else {
dist = sdRoundedRect(inLocalPos, shape.halfSize, shape.cornerRadius); outFragColor = shadeShape(shape);
} }
// Outlines grow inwards so the outer edge matches the filled shape
if (shape.outline > 0.0) {
dist = abs(dist + shape.outline * 0.5) - shape.outline * 0.5;
}
float edgeWidth = max(fwidth(dist), 1e-4);
float coverage = clamp(0.5 - dist / edgeWidth, 0.0, 1.0);
vec2 uv = mix(shape.uvRect.xy, shape.uvRect.zw, clamp(inQuadPos, 0.0, 1.0));
vec4 texColor = sampleTexture2D(shape.textureId, shape.samplerId, uv);
outFragColor = texColor * shape.color;
outFragColor.a *= coverage;
} }
+1 -1
View File
@@ -4,8 +4,8 @@
#define SHAPE_RECT 0 #define SHAPE_RECT 0
#define SHAPE_ELLIPSE 1 #define SHAPE_ELLIPSE 1
#define SHAPE_TRIANGLE 2 #define SHAPE_TRIANGLE 2
#define SHAPE_TEXT 3
// Mirrors Shape2D in Renderer2D.h
struct Shape2D { struct Shape2D {
vec2 center; vec2 center;
vec2 halfSize; vec2 halfSize;
+18 -10
View File
@@ -7,27 +7,35 @@ layout (location = 0) out vec2 outLocalPos;
layout (location = 1) out vec2 outQuadPos; layout (location = 1) out vec2 outQuadPos;
layout (location = 2) flat out uint outShapeIndex; layout (location = 2) flat out uint outShapeIndex;
// Room for the antialiased edge outside the shape's bounds
const float EDGE_MARGIN = 1.0; const float EDGE_MARGIN = 1.0;
const vec2 CORNERS[6] = vec2[]( const vec2 QUAD_CORNERS[6] = vec2[](
vec2(-1.0, -1.0), vec2(1.0, -1.0), vec2(1.0, 1.0), vec2(-1.0, -1.0), vec2(1.0, -1.0), vec2(1.0, 1.0),
vec2(-1.0, -1.0), vec2(1.0, 1.0), vec2(-1.0, 1.0) vec2(-1.0, -1.0), vec2(1.0, 1.0), vec2(-1.0, 1.0)
); );
vec2 rotate(vec2 position, float angle)
{
float cosAngle = cos(angle);
float sinAngle = sin(angle);
return vec2(
position.x * cosAngle - position.y * sinAngle,
position.x * sinAngle + position.y * cosAngle
);
}
void main() void main()
{ {
Shape2D shape = pcs.shapeBuffer.shapes[gl_InstanceIndex]; Shape2D shape = pcs.shapeBuffer.shapes[gl_InstanceIndex];
vec2 corner = CORNERS[gl_VertexIndex]; vec2 corner = QUAD_CORNERS[gl_VertexIndex];
vec2 localPos = corner * (shape.halfSize + EDGE_MARGIN); vec2 localPosition = corner * (shape.halfSize + EDGE_MARGIN);
vec2 screenPosition = shape.center + rotate(localPosition, shape.rotation);
float c = cos(shape.rotation); gl_Position = pcs.projection * vec4(screenPosition, 0.0, 1.0);
float s = sin(shape.rotation);
vec2 rotated = vec2(localPos.x * c - localPos.y * s, localPos.x * s + localPos.y * c);
gl_Position = pcs.projection * vec4(shape.center + rotated, 0.0, 1.0); outLocalPos = localPosition;
outLocalPos = localPos; outQuadPos = localPosition / max(shape.halfSize, vec2(1e-6)) * 0.5 + 0.5;
outQuadPos = localPos / max(shape.halfSize, vec2(1e-6)) * 0.5 + 0.5;
outShapeIndex = gl_InstanceIndex; outShapeIndex = gl_InstanceIndex;
} }
@@ -2,6 +2,7 @@
#define DESTRUM_RENDERER2D_H #define DESTRUM_RENDERER2D_H
#include <cstdint> #include <cstdint>
#include <string_view>
#include <vector> #include <vector>
#include <glm/glm.hpp> #include <glm/glm.hpp>
@@ -9,10 +10,18 @@
#include <destrum/Graphics/ids.h> #include <destrum/Graphics/ids.h>
#include <destrum/Singleton.h> #include <destrum/Singleton.h>
// windows.h maps DrawText to DrawTextA/W
#ifdef DrawText
#undef DrawText
#endif
class Font;
enum class Shape2DType : std::uint32_t { enum class Shape2DType : std::uint32_t {
Rect = 0, Rect = 0,
Ellipse = 1, Ellipse = 1,
Triangle = 2, Triangle = 2,
Text = 3,
}; };
enum class TextureFilter : std::uint32_t { enum class TextureFilter : std::uint32_t {
@@ -41,7 +50,15 @@ struct ShapeStyle {
Texture2D texture{}; Texture2D texture{};
}; };
// Mirrors Shape2D in shape2d.glsl (scalar layout) struct TextStyle {
glm::vec4 color{1.0f};
// Grows outwards from the glyphs, at most the font's SDF spread
float outline{0.0f};
glm::vec4 outlineColor{0.0f, 0.0f, 0.0f, 1.0f};
};
// Mirrors Shape2D in shape2d.glsl (scalar layout). Text glyphs store the SDF distance
// scale in p0.x and the outline colour in p1/p2.
struct Shape2D { struct Shape2D {
glm::vec2 center; glm::vec2 center;
glm::vec2 halfSize; glm::vec2 halfSize;
@@ -94,6 +111,20 @@ public:
const glm::vec4& color, const glm::vec4& color,
LineCap cap = LineCap::Butt); LineCap cap = LineCap::Butt);
// size is the line size in pixels, the font has to be uploaded
void DrawText(
const Font& font,
std::string_view text,
const glm::vec2& topLeft,
float size,
const glm::vec4& color);
void DrawText(
const Font& font,
std::string_view text,
const glm::vec2& topLeft,
float size,
const TextStyle& style);
[[nodiscard]] const std::vector<Shape2D>& GetShapes() const { return shapes; } [[nodiscard]] const std::vector<Shape2D>& GetShapes() const { return shapes; }
void Clear(); void Clear();
@@ -0,0 +1,110 @@
#ifndef DESTRUM_FONT_H
#define DESTRUM_FONT_H
#include <cstdint>
#include <filesystem>
#include <string_view>
#include <unordered_map>
#include <vector>
#include <glm/glm.hpp>
#include <destrum/Graphics/ids.h>
class GfxDevice;
class RenderResources;
struct FontGlyph {
// In atlas pixels, relative to the pen position on the baseline, y down
glm::vec2 offset{0.0f};
glm::vec2 size{0.0f};
float advance{0.0f};
glm::vec2 uvMin{0.0f};
glm::vec2 uvMax{0.0f};
};
struct PlacedGlyph {
const FontGlyph* glyph;
glm::vec2 topLeft;
glm::vec2 size;
};
// Signed distance field glyph atlas, so one atlas renders crisp text at any size
class Font final {
public:
static constexpr float DefaultPixelSize = 48.0f;
static constexpr int SdfSpread = 8;
static Font LoadFromFile(const std::filesystem::path& path, float pixelSize = DefaultPixelSize);
void Upload(GfxDevice& gfxDevice, RenderResources& resources);
[[nodiscard]] const FontGlyph* GetGlyph(char32_t codepoint) const;
[[nodiscard]] float GetKerning(char32_t left, char32_t right) const;
// Calls onGlyph(const PlacedGlyph&) for every visible glyph, in screen pixels
template <typename F>
void Layout(std::string_view text, const glm::vec2& topLeft, float size, F&& onGlyph) const;
[[nodiscard]] glm::vec2 MeasureText(std::string_view text, float size) const;
[[nodiscard]] float GetPixelSize() const { return pixelSize; }
[[nodiscard]] float GetAscender() const { return ascender; }
[[nodiscard]] float GetLineHeight() const { return lineHeight; }
[[nodiscard]] ImageID GetAtlas() const { return atlas; }
[[nodiscard]] glm::uvec2 GetAtlasSize() const { return atlasSize; }
[[nodiscard]] const std::vector<std::uint8_t>& GetAtlasPixels() const { return atlasPixels; }
static char32_t DecodeUtf8(std::string_view text, std::size_t& index);
private:
float pixelSize{0.0f};
float ascender{0.0f};
float lineHeight{0.0f};
std::unordered_map<char32_t, FontGlyph> glyphs;
std::unordered_map<std::uint64_t, float> kerning;
glm::uvec2 atlasSize{0};
std::vector<std::uint8_t> atlasPixels;
ImageID atlas{NULL_IMAGE_ID};
};
template <typename F>
void Font::Layout(std::string_view text, const glm::vec2& topLeft, float size, F&& onGlyph) const
{
const float scale = size / pixelSize;
glm::vec2 pen{topLeft.x, topLeft.y + ascender * scale};
char32_t previous = 0;
for (std::size_t i = 0; i < text.size();) {
const char32_t codepoint = DecodeUtf8(text, i);
if (codepoint == U'\n') {
pen = {topLeft.x, pen.y + lineHeight * scale};
previous = 0;
continue;
}
const FontGlyph* glyph = GetGlyph(codepoint);
if (!glyph) {
continue;
}
if (previous != 0) {
pen.x += GetKerning(previous, codepoint) * scale;
}
previous = codepoint;
if (glyph->size.x > 0.0f && glyph->size.y > 0.0f) {
onGlyph(PlacedGlyph{
.glyph = glyph,
.topLeft = pen + glyph->offset * scale,
.size = glyph->size * scale,
});
}
pen.x += glyph->advance * scale;
}
}
#endif // DESTRUM_FONT_H
@@ -3,6 +3,8 @@
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
#include <destrum/Graphics/Resources/Font.h>
void Renderer2D::DrawRect(const glm::vec2& topLeft, const glm::vec2& size, const glm::vec4& color) void Renderer2D::DrawRect(const glm::vec2& topLeft, const glm::vec2& size, const glm::vec4& color)
{ {
DrawRect(topLeft, size, ShapeStyle{.color = 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() void Renderer2D::Clear()
{ {
shapes.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;
}
+2
View File
@@ -8,6 +8,7 @@
#include <destrum/Graphics/RenderResources.h> #include <destrum/Graphics/RenderResources.h>
#include "destrum/Graphics/Resources/Cubemap.h" #include "destrum/Graphics/Resources/Cubemap.h"
#include "destrum/Graphics/Resources/Font.h"
#include "destrum/ObjectModel/GameObject.h" #include "destrum/ObjectModel/GameObject.h"
#include <scenes/MainScene.h> #include <scenes/MainScene.h>
@@ -51,6 +52,7 @@ private:
MainSceneObjects mainScene{}; MainSceneObjects mainScene{};
ImageID debug2DTexture{NULL_IMAGE_ID}; ImageID debug2DTexture{NULL_IMAGE_ID};
Font hudFont;
char scenePath[512]{"scenes/debug_scene.json"}; char scenePath[512]{"scenes/debug_scene.json"};
char newSceneName[128]{"EmptyScene"}; char newSceneName[128]{"EmptyScene"};
+13
View File
@@ -41,6 +41,9 @@ void LightKeeper::customInit()
debug2DTexture = resources.loadImageFromFile( debug2DTexture = resources.loadImageFromFile(
gfxDevice, AssetFS::GetInstance().GetFullPath("engine://textures/kobe.png")); gfxDevice, AssetFS::GetInstance().GetFullPath("engine://textures/kobe.png"));
hudFont = Font::LoadFromFile(AssetFS::GetInstance().GetFullPath("engine://fonts/Roboto-Medium.ttf"));
hudFont.Upload(gfxDevice, resources);
} }
void LightKeeper::setupSkybox() void LightKeeper::setupSkybox()
@@ -91,6 +94,16 @@ void LightKeeper::drawCrosshair()
renderer2D.DrawLine(center - glm::vec2{0.0f, halfArm}, center + glm::vec2{0.0f, halfArm}, thickness, color); renderer2D.DrawLine(center - glm::vec2{0.0f, halfArm}, center + glm::vec2{0.0f, halfArm}, thickness, color);
if (focused) { if (focused) {
renderer2D.DrawEllipseOutline(center, glm::vec2{14.0f}, thickness, color); renderer2D.DrawEllipseOutline(center, glm::vec2{14.0f}, thickness, color);
constexpr std::string_view prompt = "[E] Interact";
constexpr float promptSize = 22.0f;
const glm::vec2 promptExtent = hudFont.MeasureText(prompt, promptSize);
renderer2D.DrawText(
hudFont,
prompt,
center + glm::vec2{-promptExtent.x * 0.5f, 28.0f},
promptSize,
TextStyle{.color = color, .outline = 2.0f, .outlineColor = {0.0f, 0.0f, 0.0f, 0.75f}});
} }
} }
+49
View File
@@ -1,3 +1,4 @@
#include <algorithm>
#include <cmath> #include <cmath>
#include <cstdlib> #include <cstdlib>
#include <cstring> #include <cstring>
@@ -33,6 +34,7 @@
#include <destrum/Components/CameraComponent.h> #include <destrum/Components/CameraComponent.h>
#include <destrum/Graphics/Resources/PrimitiveMeshes.h> #include <destrum/Graphics/Resources/PrimitiveMeshes.h>
#include <destrum/Graphics/Managers/Renderer2D.h> #include <destrum/Graphics/Managers/Renderer2D.h>
#include <destrum/Graphics/Resources/Font.h>
#include <destrum/Components/LightComponent.h> #include <destrum/Components/LightComponent.h>
#include <destrum/Components/Physics/Rigidbody.h> #include <destrum/Components/Physics/Rigidbody.h>
#include <destrum/Graphics/Material.h> #include <destrum/Graphics/Material.h>
@@ -478,6 +480,52 @@ namespace {
check(r2d.GetShapes().empty(), "clear must drop all submitted shapes"); check(r2d.GetShapes().empty(), "clear must drop all submitted shapes");
} }
void testFontAtlasAndLayout()
{
const std::filesystem::path fontPath =
std::filesystem::path{DESTRUM_ENGINE_ASSETS_SRC_DIR} / "fonts" / "Roboto-Medium.ttf";
const Font font = Font::LoadFromFile(fontPath, 32.0f);
const FontGlyph* a = font.GetGlyph(U'A');
const FontGlyph* space = font.GetGlyph(U' ');
check(a != nullptr && a->size.x > 0.0f && a->size.y > 0.0f && a->advance > 0.0f &&
a->uvMax.x > a->uvMin.x && a->uvMax.y > a->uvMin.y,
"printable glyphs must be rasterized into the atlas");
check(space != nullptr && space->size == glm::vec2{0.0f} && space->advance > 0.0f,
"spaces must only advance the pen");
check(font.GetGlyph(U'中') == font.GetGlyph(U'?'), "missing glyphs must fall back to '?'");
const auto& pixels = font.GetAtlasPixels();
const auto [minIt, maxIt] = std::minmax_element(pixels.begin(), pixels.end());
check(!pixels.empty() && *minIt < 128 && *maxIt > 128,
"the atlas must hold a signed distance field with the edge at 128");
const glm::vec2 one = font.MeasureText("A", 64.0f);
const glm::vec2 two = font.MeasureText("A\nAA", 64.0f);
check(close(one.x, a->advance * 2.0f) && close(one.y, font.GetLineHeight() * 2.0f),
"text must be measured at the requested size");
check(close(two.x, (a->advance * 2.0f + font.GetKerning(U'A', U'A')) * 2.0f) &&
close(two.y, one.y * 2.0f),
"multi line text must measure its widest line and every line");
std::vector<PlacedGlyph> placed;
font.Layout("A b", {100.0f, 50.0f}, 32.0f, [&](const PlacedGlyph& glyph) { placed.push_back(glyph); });
check(placed.size() == 2 && placed[0].glyph == a &&
close(placed[0].topLeft.x, 100.0f + a->offset.x) &&
close(placed[0].topLeft.y, 50.0f + font.GetAscender() + a->offset.y),
"layout must skip spaces and start on the baseline below the top left");
const std::string_view utf8 = "\xC3\xA9\xFF";
std::size_t index = 0;
check(Font::DecodeUtf8(utf8, index) == U'é' && index == 2, "utf8 must decode two byte sequences");
check(Font::DecodeUtf8(utf8, index) == 0xFFFD && index == 3, "invalid utf8 must decode to U+FFFD");
auto& r2d = Renderer2D::GetInstance();
r2d.Clear();
r2d.DrawText(font, "not uploaded", {0.0f, 0.0f}, 16.0f, glm::vec4{1.0f});
check(r2d.GetShapes().empty(), "text must not be drawn before the font atlas is uploaded");
}
void testCapsuleColliderDebugLines() void testCapsuleColliderDebugLines()
{ {
GameObject object{"capsule"}; GameObject object{"capsule"};
@@ -1179,6 +1227,7 @@ int main()
testColliderCenterOffset(); testColliderCenterOffset();
testCapsuleColliderDebugLines(); testCapsuleColliderDebugLines();
testRenderer2DShapes(); testRenderer2DShapes();
testFontAtlasAndLayout();
std::cout << "destrum tests passed\n"; std::cout << "destrum tests passed\n";
return EXIT_SUCCESS; return EXIT_SUCCESS;
} }