feat: add text rendering with SDF's
This commit is contained in:
@@ -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"
|
||||
|
||||
Binary file not shown.
@@ -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 sdTriangle(vec2 p, vec2 p0, vec2 p1, vec2 p2)
|
||||
float gradientLength = length(position / (radii * radii));
|
||||
return normalizedLength * (normalizedLength - 1.0) / gradientLength;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#define DESTRUM_RENDERER2D_H
|
||||
|
||||
#include <cstdint>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
#include <glm/glm.hpp>
|
||||
@@ -9,10 +10,18 @@
|
||||
#include <destrum/Graphics/ids.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 {
|
||||
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<Shape2D>& GetShapes() const { return shapes; }
|
||||
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 <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;
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
#include <destrum/Graphics/RenderResources.h>
|
||||
|
||||
#include "destrum/Graphics/Resources/Cubemap.h"
|
||||
#include "destrum/Graphics/Resources/Font.h"
|
||||
#include "destrum/ObjectModel/GameObject.h"
|
||||
|
||||
#include <scenes/MainScene.h>
|
||||
@@ -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"};
|
||||
|
||||
@@ -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}});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
@@ -33,6 +34,7 @@
|
||||
#include <destrum/Components/CameraComponent.h>
|
||||
#include <destrum/Graphics/Resources/PrimitiveMeshes.h>
|
||||
#include <destrum/Graphics/Managers/Renderer2D.h>
|
||||
#include <destrum/Graphics/Resources/Font.h>
|
||||
#include <destrum/Components/LightComponent.h>
|
||||
#include <destrum/Components/Physics/Rigidbody.h>
|
||||
#include <destrum/Graphics/Material.h>
|
||||
@@ -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<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()
|
||||
{
|
||||
GameObject object{"capsule"};
|
||||
@@ -1179,6 +1227,7 @@ int main()
|
||||
testColliderCenterOffset();
|
||||
testCapsuleColliderDebugLines();
|
||||
testRenderer2DShapes();
|
||||
testFontAtlasAndLayout();
|
||||
std::cout << "destrum tests passed\n";
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user