diff --git a/destrum/CMakeLists.txt b/destrum/CMakeLists.txt index 4be1012..38b9401 100644 --- a/destrum/CMakeLists.txt +++ b/destrum/CMakeLists.txt @@ -78,6 +78,7 @@ set(SRC_FILES "src/Graphics/Resources/GPUImage.cpp" "src/Graphics/Resources/NBuffer.cpp" "src/Graphics/Resources/Cubemap.cpp" + "src/Graphics/Resources/Font.cpp" "src/Graphics/Resources/PrimitiveMeshes.cpp" "src/Graphics/Pipelines/MeshPipeline.cpp" diff --git a/destrum/assets_src/fonts/Roboto-Medium.ttf b/destrum/assets_src/fonts/Roboto-Medium.ttf new file mode 100644 index 0000000..39c63d7 Binary files /dev/null and b/destrum/assets_src/fonts/Roboto-Medium.ttf differ diff --git a/destrum/assets_src/shaders/shape2d.frag b/destrum/assets_src/shaders/shape2d.frag index 8a2b5e7..e73be86 100644 --- a/destrum/assets_src/shaders/shape2d.frag +++ b/destrum/assets_src/shaders/shape2d.frag @@ -11,60 +11,152 @@ layout (location = 2) flat in uint inShapeIndex; 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)); - vec2 q = abs(p) - halfSize + radius; - return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - radius; + cornerRadius = min(cornerRadius, min(halfSize.x, halfSize.y)); + + 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 -float sdEllipse(vec2 p, vec2 radii) +float ellipseDistance(vec2 position, vec2 radii) { radii = max(radii, vec2(1e-4)); - float k0 = length(p / radii); - float k1 = length(p / (radii * radii)); - return k0 < 1e-4 ? -min(radii.x, radii.y) : k0 * (k0 - 1.0) / k1; + + float normalizedLength = length(position / radii); + 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 v0 = p - p0, v1 = p - p1, v2 = p - p2; - 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); - vec2 pq2 = v2 - e2 * clamp(dot(v2, e2) / max(dot(e2, e2), 1e-8), 0.0, 1.0); - float winding = sign(e0.x * e2.y - e0.y * e2.x); - 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 -sqrt(d.x) * sign(d.y); + vec2 segment = segmentEnd - segmentStart; + vec2 toPosition = position - segmentStart; + + float along = clamp( + dot(toPosition, segment) / max(dot(segment, segment), 1e-8), + 0.0, 1.0 + ); + + return toPosition - segment * along; +} + +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() { Shape2D shape = pcs.shapeBuffer.shapes[inShapeIndex]; - float dist; - if (shape.type == SHAPE_ELLIPSE) { - dist = sdEllipse(inLocalPos, shape.halfSize); - } else if (shape.type == SHAPE_TRIANGLE) { - dist = sdTriangle(inLocalPos, shape.p0, shape.p1, shape.p2); + if (shape.type == SHAPE_TEXT) { + outFragColor = shadeText(shape); } 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; } diff --git a/destrum/assets_src/shaders/shape2d.glsl b/destrum/assets_src/shaders/shape2d.glsl index 0ed296c..b4785cd 100644 --- a/destrum/assets_src/shaders/shape2d.glsl +++ b/destrum/assets_src/shaders/shape2d.glsl @@ -4,8 +4,8 @@ #define SHAPE_RECT 0 #define SHAPE_ELLIPSE 1 #define SHAPE_TRIANGLE 2 +#define SHAPE_TEXT 3 -// Mirrors Shape2D in Renderer2D.h struct Shape2D { vec2 center; vec2 halfSize; diff --git a/destrum/assets_src/shaders/shape2d.vert b/destrum/assets_src/shaders/shape2d.vert index 6a0ac57..26ec81a 100644 --- a/destrum/assets_src/shaders/shape2d.vert +++ b/destrum/assets_src/shaders/shape2d.vert @@ -7,27 +7,35 @@ layout (location = 0) out vec2 outLocalPos; layout (location = 1) out vec2 outQuadPos; layout (location = 2) flat out uint outShapeIndex; -// Room for the antialiased edge outside the shape's bounds 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 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() { Shape2D shape = pcs.shapeBuffer.shapes[gl_InstanceIndex]; - vec2 corner = CORNERS[gl_VertexIndex]; - vec2 localPos = corner * (shape.halfSize + EDGE_MARGIN); + vec2 corner = QUAD_CORNERS[gl_VertexIndex]; + vec2 localPosition = corner * (shape.halfSize + EDGE_MARGIN); + vec2 screenPosition = shape.center + rotate(localPosition, shape.rotation); - float c = cos(shape.rotation); - 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(screenPosition, 0.0, 1.0); - gl_Position = pcs.projection * vec4(shape.center + rotated, 0.0, 1.0); - outLocalPos = localPos; - outQuadPos = localPos / max(shape.halfSize, vec2(1e-6)) * 0.5 + 0.5; + outLocalPos = localPosition; + outQuadPos = localPosition / max(shape.halfSize, vec2(1e-6)) * 0.5 + 0.5; outShapeIndex = gl_InstanceIndex; } diff --git a/destrum/include/destrum/Graphics/Managers/Renderer2D.h b/destrum/include/destrum/Graphics/Managers/Renderer2D.h index af924f3..2d3ae14 100644 --- a/destrum/include/destrum/Graphics/Managers/Renderer2D.h +++ b/destrum/include/destrum/Graphics/Managers/Renderer2D.h @@ -2,6 +2,7 @@ #define DESTRUM_RENDERER2D_H #include +#include #include #include @@ -9,10 +10,18 @@ #include #include +// windows.h maps DrawText to DrawTextA/W +#ifdef DrawText +#undef DrawText +#endif + +class Font; + enum class Shape2DType : std::uint32_t { Rect = 0, Ellipse = 1, Triangle = 2, + Text = 3, }; enum class TextureFilter : std::uint32_t { @@ -41,7 +50,15 @@ struct ShapeStyle { 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 { glm::vec2 center; glm::vec2 halfSize; @@ -94,6 +111,20 @@ public: const glm::vec4& color, 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& GetShapes() const { return shapes; } void Clear(); diff --git a/destrum/include/destrum/Graphics/Resources/Font.h b/destrum/include/destrum/Graphics/Resources/Font.h new file mode 100644 index 0000000..762dc9e --- /dev/null +++ b/destrum/include/destrum/Graphics/Resources/Font.h @@ -0,0 +1,110 @@ +#ifndef DESTRUM_FONT_H +#define DESTRUM_FONT_H + +#include +#include +#include +#include +#include + +#include + +#include + +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 + 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& 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 glyphs; + std::unordered_map kerning; + + glm::uvec2 atlasSize{0}; + std::vector atlasPixels; + ImageID atlas{NULL_IMAGE_ID}; +}; + +template +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 diff --git a/destrum/src/Graphics/Managers/Renderer2D.cpp b/destrum/src/Graphics/Managers/Renderer2D.cpp index 2bbfcff..f32ce0c 100644 --- a/destrum/src/Graphics/Managers/Renderer2D.cpp +++ b/destrum/src/Graphics/Managers/Renderer2D.cpp @@ -3,6 +3,8 @@ #include #include +#include + 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(Font::SdfSpread) * scale * (255.0f / 128.0f); + const float outline = std::clamp(style.outline, 0.0f, static_cast(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(); diff --git a/destrum/src/Graphics/Resources/Font.cpp b/destrum/src/Graphics/Resources/Font.cpp new file mode 100644 index 0000000..3e5a48f --- /dev/null +++ b/destrum/src/Graphics/Resources/Font.cpp @@ -0,0 +1,338 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include +#include FT_FREETYPE_H +#include FT_MODULE_H + +#include + +#include +#include + +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 pixels; + }; + + std::uint64_t kerningKey(char32_t left, char32_t right) + { + return (static_cast(left) << 32) | static_cast(right); + } + + std::vector 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(file), std::istreambuf_iterator()}; + } + + 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 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(fileData.size()), 0, &face.handle), + "FT_New_Memory_Face", + path); + check(FT_Set_Pixel_Sizes(face.handle, 0, static_cast(pixelSize)), "FT_Set_Pixel_Sizes", path); + + Font font; + font.pixelSize = static_cast(static_cast(pixelSize)); + font.ascender = static_cast(face.handle->size->metrics.ascender) / 64.0f; + font.lineHeight = static_cast(face.handle->size->metrics.height) / 64.0f; + + std::vector 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(codepoint), path.string()); + continue; + } + + FT_GlyphSlot slot = face.handle->glyph; + RasterizedGlyph entry{ + .codepoint = codepoint, + .glyph = {.advance = static_cast(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(row) * bitmap.pitch, + entry.width); + } + entry.glyph.offset = { + static_cast(slot->bitmap_left), + -static_cast(slot->bitmap_top), + }; + entry.glyph.size = {static_cast(entry.width), static_cast(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 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 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(x), static_cast(y)} / atlasExtent; + entry.glyph.uvMax = glm::vec2{static_cast(x + entry.width), static_cast(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(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(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(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; +} diff --git a/lightkeeper/include/Lightkeeper.h b/lightkeeper/include/Lightkeeper.h index 2319efd..28bc1be 100644 --- a/lightkeeper/include/Lightkeeper.h +++ b/lightkeeper/include/Lightkeeper.h @@ -8,6 +8,7 @@ #include #include "destrum/Graphics/Resources/Cubemap.h" +#include "destrum/Graphics/Resources/Font.h" #include "destrum/ObjectModel/GameObject.h" #include @@ -51,6 +52,7 @@ private: MainSceneObjects mainScene{}; ImageID debug2DTexture{NULL_IMAGE_ID}; + Font hudFont; char scenePath[512]{"scenes/debug_scene.json"}; char newSceneName[128]{"EmptyScene"}; diff --git a/lightkeeper/src/Lightkeeper.cpp b/lightkeeper/src/Lightkeeper.cpp index a3ba5b5..b0ad4c6 100644 --- a/lightkeeper/src/Lightkeeper.cpp +++ b/lightkeeper/src/Lightkeeper.cpp @@ -41,6 +41,9 @@ void LightKeeper::customInit() debug2DTexture = resources.loadImageFromFile( 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() @@ -91,6 +94,16 @@ void LightKeeper::drawCrosshair() renderer2D.DrawLine(center - glm::vec2{0.0f, halfArm}, center + glm::vec2{0.0f, halfArm}, thickness, color); if (focused) { 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}}); } } diff --git a/tests/destrum_tests.cpp b/tests/destrum_tests.cpp index 69b48d6..f552bd8 100644 --- a/tests/destrum_tests.cpp +++ b/tests/destrum_tests.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -33,6 +34,7 @@ #include #include #include +#include #include #include #include @@ -478,6 +480,52 @@ namespace { 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 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() { GameObject object{"capsule"}; @@ -1179,6 +1227,7 @@ int main() testColliderCenterOffset(); testCapsuleColliderDebugLines(); testRenderer2DShapes(); + testFontAtlasAndLayout(); std::cout << "destrum tests passed\n"; return EXIT_SUCCESS; }