diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 2103d0561..b7eb179e6 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -2,6 +2,8 @@ set(mmapper_SRCS main.cpp adventure/AdventureLogModel.cpp adventure/AdventureLogModel.h + font/GlyphAtlas.cpp + font/GlyphAtlas.h adventure/adventuresession.cpp adventure/adventuresession.h adventure/adventuretracker.cpp diff --git a/src/configuration/NamedConfig.h b/src/configuration/NamedConfig.h index 14e72a823..82ebc6a42 100644 --- a/src/configuration/NamedConfig.h +++ b/src/configuration/NamedConfig.h @@ -15,7 +15,7 @@ class NODISCARD NamedConfig final private: std::string m_name; ChangeMonitor m_changeMonitor; - T m_value = 0; + T m_value{}; bool m_notifying = false; public: diff --git a/src/configuration/configuration.cpp b/src/configuration/configuration.cpp index 4ebe6032d..89dc485a4 100644 --- a/src/configuration/configuration.cpp +++ b/src/configuration/configuration.cpp @@ -304,6 +304,8 @@ ConstString KEY_USE_INTERNAL_EDITOR = "Use internal editor"; ConstString KEY_USE_TRILINEAR_FILTERING = "Use trilinear filtering"; ConstString KEY_WEATHER_ATMOSPHERE_INTENSITY = "weather.atmosphereIntensity"; ConstString KEY_WEATHER_PRECIPITATION_INTENSITY = "weather.precipitationIntensity"; +ConstString KEY_MAP_FONT_FAMILY = "Map font family"; +ConstString KEY_MAP_FONT_SIZE = "Map font point size"; ConstString KEY_WEATHER_TIME_OF_DAY_INTENSITY = "weather.todIntensity"; ConstString KEY_WINDOW_GEOMETRY = "Window Geometry"; ConstString KEY_WINDOW_STATE = "Window State"; @@ -686,6 +688,9 @@ void Configuration::CanvasSettings::read(const QSettings &conf) .append(DEFAULT_MMAPPER_SUBDIR) .append(DEFAULT_RESOURCES_SUBDIR)) .toString(); + mapFontFamily.set(conf.value(KEY_MAP_FONT_FAMILY, mapFontFamily.get()).toString()); + mapFontPointSize.set(conf.value(KEY_MAP_FONT_SIZE, mapFontPointSize.get()).toInt()); + mapFontPointSize.clamp(6, 72); showMissingMapId.set(conf.value(KEY_SHOW_MISSING_MAP_ID, true).toBool()); showUnsavedChanges.set(conf.value(KEY_SHOW_UNSAVED_CHANGES, true).toBool()); showUnmappedExits.set(conf.value(KEY_DRAW_NOT_MAPPED_EXITS, true).toBool()); @@ -897,6 +902,8 @@ NODISCARD static auto getQColorName(const XNamedColor &color) void Configuration::CanvasSettings::write(QSettings &conf) const { conf.setValue(KEY_RESOURCES_DIRECTORY, resourcesDirectory); + conf.setValue(KEY_MAP_FONT_FAMILY, mapFontFamily.get()); + conf.setValue(KEY_MAP_FONT_SIZE, mapFontPointSize.get()); conf.setValue(KEY_SHOW_MISSING_MAP_ID, showMissingMapId.get()); conf.setValue(KEY_SHOW_UNSAVED_CHANGES, showUnsavedChanges.get()); conf.setValue(KEY_DRAW_NOT_MAPPED_EXITS, showUnmappedExits.get()); diff --git a/src/configuration/configuration.h b/src/configuration/configuration.h index e2fa18c8a..64223a8b0 100644 --- a/src/configuration/configuration.h +++ b/src/configuration/configuration.h @@ -182,6 +182,8 @@ class NODISCARD Configuration final NamedConfig showMissingMapId{"SHOW_MISSING_MAPID", false}; NamedConfig showUnsavedChanges{"SHOW_UNSAVED_CHANGES", false}; NamedConfig showUnmappedExits{"SHOW_UNMAPPED_EXITS", false}; + NamedConfig mapFontFamily{"MAP_FONT_FAMILY", QStringLiteral("Cantarell")}; + NamedConfig mapFontPointSize{"MAP_FONT_POINT_SIZE", platformPointSize(11)}; bool drawUpperLayersTextured = false; bool drawDoorNames = false; bool softwareOpenGL = false; diff --git a/src/display/Characters.cpp b/src/display/Characters.cpp index 93b17e82e..5ec665e4b 100644 --- a/src/display/Characters.cpp +++ b/src/display/Characters.cpp @@ -544,7 +544,7 @@ void MapCanvas::drawGroupCharacters(CharacterBatch &batch, ServerRoomId yourServ QString name = character.getLabel().isEmpty() ? character.getName().toQString() : character.getLabel().toQString(); if (!name.isEmpty()) { - batch.drawName(pos, mmqt::toStdStringLatin1(name), color); + batch.drawName(pos, mmqt::toStdStringUtf8(name), color); } } diff --git a/src/display/Connections.cpp b/src/display/Connections.cpp index dea36f946..8f1726a0c 100644 --- a/src/display/Connections.cpp +++ b/src/display/Connections.cpp @@ -223,7 +223,7 @@ void ConnectionDrawer::drawRoomDoorName(const RoomHandle &sourceRoom, static const auto bg = Colors::black.withAlpha(0.4f); const glm::vec3 pos{xy, static_cast(m_currentLayer)}; m_roomNameBatch.emplace_back(GLText{pos, - mmqt::toStdStringLatin1(name), // GL font is latin1 + mmqt::toStdStringUtf8(name), Colors::white, bg, FontFormatFlags{FontFormatFlagEnum::HALIGN_CENTER}}); diff --git a/src/display/Infomarks.cpp b/src/display/Infomarks.cpp index 0cbd49053..0c5786c0f 100644 --- a/src/display/Infomarks.cpp +++ b/src/display/Infomarks.cpp @@ -263,9 +263,8 @@ void MapCanvas::drawInfomark(InfomarksBatch &batch, switch (infoMarkType) { case InfomarkTypeEnum::TEXT: { const auto utf8 = marker.getText().getStdStringViewUtf8(); - const auto latin1_to_render = charset::conversion::utf8ToLatin1(utf8); // GL font is latin1 batch.renderText(pos, - latin1_to_render, + std::string{utf8}, textColor(bgColor), bgColor, fontFormatFlag, diff --git a/src/display/MapCanvasWindow.h b/src/display/MapCanvasWindow.h index 9f21681e7..ff98c5023 100644 --- a/src/display/MapCanvasWindow.h +++ b/src/display/MapCanvasWindow.h @@ -24,6 +24,7 @@ #include #include +#include #include class ConnectionSelection; @@ -63,6 +64,11 @@ class NODISCARD_QOBJECT MapCanvasWindow final : public QOpenGLWindow, private Ma // MapCanvasHost void virt_requestCanvasUpdate() final { update(); } NODISCARD qreal virt_hostDevicePixelRatio() const final { return devicePixelRatioF(); } + NODISCARD qreal virt_hostLogicalDpi() const final + { + const QScreen *const s = screen(); + return (s != nullptr) ? s->logicalDotsPerInchY() : 96.0; + } NODISCARD QSize virt_hostSize() const final { return QSize(QOpenGLWindow::width(), QOpenGLWindow::height()); diff --git a/src/display/mapcanvas.cpp b/src/display/mapcanvas.cpp index 34fdfe499..6a928e99f 100644 --- a/src/display/mapcanvas.cpp +++ b/src/display/mapcanvas.cpp @@ -1420,6 +1420,13 @@ void MapCanvas::screenChanged() const auto newDpi = static_cast(currentDpr()); const auto oldDpi = gl.getHostDevicePixelRatio(); + // same DPR but different logical DPI (e.g. per-monitor font scaling) + if (utils::isSameFloat(newDpi, oldDpi) + && !utils::isSameFloat(currentLogicalDpi(), getGLFont().getLogicalDpi())) { + m_pendingFontReload = true; + m_frameManager.requestUpdate(); + } + if (!utils::isSameFloat(newDpi, oldDpi)) { log(QString("Display: %1 DPI").arg(static_cast(newDpi))); diff --git a/src/display/mapcanvas.h b/src/display/mapcanvas.h index 3eae2e918..70777385e 100644 --- a/src/display/mapcanvas.h +++ b/src/display/mapcanvas.h @@ -73,6 +73,9 @@ class NODISCARD MapCanvasHost // The host's current device pixel ratio. NODISCARD qreal hostDevicePixelRatio() const { return virt_hostDevicePixelRatio(); } + // The logical DPI of the host's current screen. + NODISCARD qreal hostLogicalDpi() const { return virt_hostLogicalDpi(); } + // The host's current size in logical pixels. NODISCARD QSize hostSize() const { return virt_hostSize(); } @@ -92,6 +95,7 @@ class NODISCARD MapCanvasHost private: virtual void virt_requestCanvasUpdate() = 0; NODISCARD virtual qreal virt_hostDevicePixelRatio() const = 0; + NODISCARD virtual qreal virt_hostLogicalDpi() const = 0; NODISCARD virtual QSize virt_hostSize() const = 0; virtual void virt_setHostCursor(Qt::CursorShape shape) = 0; virtual void virt_applyPresentViewport() {} @@ -204,6 +208,8 @@ class NODISCARD_QOBJECT MapCanvas final : public QObject, bool m_pendingUpdateTextures = false; std::optional m_pendingDpr; std::optional m_pendingRenderScale; + // font preference or logical DPI changed; applied like m_pendingRenderScale + bool m_pendingFontReload = false; // Touch long-press -> context menu. A left press synthesized from touch // arms the timer; moving past the drag threshold or releasing disarms it @@ -236,6 +242,10 @@ class NODISCARD_QOBJECT MapCanvas final : public QObject, private: NODISCARD qreal currentDpr() const { return m_host.hostDevicePixelRatio(); } + NODISCARD float currentLogicalDpi() const + { + return static_cast(m_host.hostLogicalDpi()); + } // Configuration::canvas.renderScale (a percentage) as the factor the // offscreen FBO is rendered at; see Legacy::Functions::setRenderScale(). NODISCARD static float configuredRenderScale(); diff --git a/src/display/mapcanvas_gl.cpp b/src/display/mapcanvas_gl.cpp index 0b26c2a64..c352dac6a 100644 --- a/src/display/mapcanvas_gl.cpp +++ b/src/display/mapcanvas_gl.cpp @@ -341,7 +341,7 @@ bool MapCanvas::hostInitializeGL() initTextures(); auto &font = getGLFont(); font.setTextureId(allocateTextureId()); - font.init(); + font.init(currentLogicalDpi()); updateTextures(); // compile all shaders @@ -386,6 +386,14 @@ bool MapCanvas::hostInitializeGL() this->requestUpdateTextures(); }); + const auto requestFontReload = [this]() { + // Deferred like m_pendingRenderScale: needs the GL context. + m_pendingFontReload = true; + m_frameManager.requestUpdate(); + }; + setConfig().canvas.mapFontFamily.registerChangeCallback(m_lifetime, requestFontReload); + setConfig().canvas.mapFontPointSize.registerChangeCallback(m_lifetime, requestFontReload); + // NOTE: The host facade is responsible for connecting to // QOpenGLContext::aboutToBeDestroyed (see MapCanvas::initializeGL()); the // core has no notion of a QOpenGLContext or its lifetime. @@ -610,7 +618,7 @@ void MapCanvas::applyPendingGLWork() getOpenGL().setDevicePixelRatio(newDpi); auto &font = getGLFont(); font.cleanup(); - font.init(); + font.init(currentLogicalDpi()); } if (m_pendingRenderScale.has_value()) { const float newScale = *m_pendingRenderScale; @@ -622,7 +630,7 @@ void MapCanvas::applyPendingGLWork() // and the FBO is sized from it. auto &font = getGLFont(); font.cleanup(); - font.init(); + font.init(currentLogicalDpi()); m_batches.resetExistingMeshesButKeepPendingRemesh(); markMultisamplingDirty(); } @@ -630,6 +638,13 @@ void MapCanvas::applyPendingGLWork() if (std::exchange(m_pendingUpdateTextures, false)) { updateTextures(); } + if (std::exchange(m_pendingFontReload, false)) { + auto &font = getGLFont(); + font.cleanup(); + font.init(currentLogicalDpi()); + // existing text meshes reference the old atlas + m_pendingForceUpdateMeshes = true; + } if (std::exchange(m_pendingForceUpdateMeshes, false)) { forceUpdateMeshes(); } @@ -948,7 +963,7 @@ void MapCanvas::hostPaintGL() auto y = lineHeight; const auto print = [lineHeight, rightMargin, &text, &y](const QString &msg) { text.emplace_back(glm::vec3(rightMargin, y, 0), - mmqt::toStdStringLatin1(msg), // GL font is latin1 + mmqt::toStdStringUtf8(msg), Colors::white, Colors::black.withAlpha(0.4f), FontFormatFlags{FontFormatFlagEnum::HALIGN_RIGHT}); diff --git a/src/font/GlyphAtlas.cpp b/src/font/GlyphAtlas.cpp new file mode 100644 index 000000000..4ddb55bac --- /dev/null +++ b/src/font/GlyphAtlas.cpp @@ -0,0 +1,385 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2025 The MMapper Authors + +#include "GlyphAtlas.h" + +#include "../global/utils.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace font { + +namespace { + +// bounds the shaped-string memo; re-shaping is cheap, glyphs stay cached +constexpr size_t MAX_SHAPED_STRINGS = 1u << 16; + +// solid white block at the atlas origin for the background and underline "glyphs" +constexpr int WHITE_BLOCK_SIZE = 4; + +// same test as FreeType's FT_HAS_COLOR +NODISCARD bool hasColorTables(const QRawFont &rawFont) +{ + for (const char *const table : {"CBDT", "sbix", "COLR", "SVG "}) { + if (!rawFont.fontTable(table).isEmpty()) { + return true; + } + } + return false; +} + +NODISCARD int roundUpPow2(int n) +{ + return static_cast(utils::nextPowerOfTwo(static_cast(std::max(1, n)))); +} + +} // namespace + +int pointSizeToPhysicalPixels(const int pointSize, + const float logicalDpi, + const float devicePixelRatio) +{ + const float dpi = (logicalDpi > 0.f) ? logicalDpi : 96.f; + const float dpr = (devicePixelRatio > 0.f) ? devicePixelRatio : 1.f; + return std::max(1, + static_cast(std::lround(static_cast(pointSize) * dpi / 72.f * dpr))); +} + +GlyphAtlas::GlyphAtlas(const Config &config) + : m_font{std::invoke([&config]() { + QFont f = config.font; + // NOTE: glyphs are displayed 1:1, so hinting is pure win + // (the pre-baked BMFont atlases were hinted, too). + f.setHintingPreference(QFont::PreferFullHinting); + f.setStyleStrategy(QFont::PreferAntialias); + return f; + })} + , m_maxSize{std::max(MIN_SIZE, roundUpPow2(config.maxSize))} +{ + const QFontMetrics fm(m_font); + m_lineHeight = std::max(1, fm.height()); + m_ascent = std::max(1, fm.ascent()); + + const int initial = std::clamp(roundUpPow2(config.initialSize), MIN_SIZE, m_maxSize); + m_image = QImage(initial, initial, QImage::Format_RGBA8888); + m_image.fill(Qt::transparent); + + // white block goes first so its location is fixed + for (int y = 0; y < WHITE_BLOCK_SIZE; ++y) { + std::memset(m_image.scanLine(y), 0xFF, WHITE_BLOCK_SIZE * 4); + } + m_shelf = Shelf{WHITE_BLOCK_SIZE, 0, WHITE_BLOCK_SIZE}; + + // interior of the block, so linear filtering at the quad edges stays white + m_background = AtlasGlyph{1, 1, 2, 2, 0, 0, false, false}; + // 1px tall, just below the baseline + m_underline = AtlasGlyph{1, 1, 2, 1, 0, -1, false, false}; + + m_resized = true; + m_dirty = m_image.rect(); +} + +GlyphAtlas::~GlyphAtlas() = default; + +ShapedText GlyphAtlas::shape(const std::string_view utf8) const +{ + const std::lock_guard lock(m_mutex); + + std::string key{utf8}; + if (const auto it = m_shaped.find(key); it != m_shaped.end()) { + return it->second; + } + + ShapedText result = shapeLocked(utf8); + if (m_shaped.size() >= MAX_SHAPED_STRINGS) { + m_shaped.clear(); + } + m_shaped.emplace(std::move(key), result); + return result; +} + +ShapedText GlyphAtlas::shapeLocked(const std::string_view utf8) const +{ + ShapedText result; + if (utf8.empty()) { + return result; + } + + QString str = QString::fromUtf8(utf8.data(), static_cast(utf8.size())); + // single line only + str.replace(QChar::LineFeed, QChar::Space); + str.replace(QChar::CarriageReturn, QChar::Space); + + QTextLayout layout(str, m_font); + QTextOption option; + option.setWrapMode(QTextOption::NoWrap); + layout.setTextOption(option); + layout.setCacheEnabled(false); + layout.beginLayout(); + const QTextLine line = layout.createLine(); + layout.endLayout(); + + if (!line.isValid()) { + return result; + } + + result.advance = qRound(line.naturalTextWidth()); + + // one run per (fallback) font + for (const QGlyphRun &run : layout.glyphRuns()) { + const QRawFont rawFont = run.rawFont(); + const QList indexes = run.glyphIndexes(); + const QList positions = run.positions(); + const auto count = std::min(indexes.size(), positions.size()); + result.glyphs.reserve(result.glyphs.size() + static_cast(count)); + for (qsizetype i = 0; i < count; ++i) { + const AtlasGlyph &glyph = lookupOrRasterizeLocked(rawFont, indexes[i]); + result.glyphs.push_back(ShapedGlyph{glyph, qRound(positions[i].x())}); + } + } + + return result; +} + +const AtlasGlyph &GlyphAtlas::lookupOrRasterizeLocked(const QRawFont &rawFont, + const uint32_t glyphIndex) const +{ + // glyph indexes are only unique within a font + const QString fontKey = rawFont.familyName() + QChar('|') + rawFont.styleName(); + auto fontIt = m_fonts.find(fontKey); + if (fontIt == m_fonts.end()) { + fontIt = m_fonts.insert(fontKey, + FontInfo{static_cast(m_fonts.size()), + hasColorTables(rawFont)}); + } + const FontInfo &info = fontIt.value(); + const uint64_t key = (static_cast(info.id) << 32u) | glyphIndex; + + if (const auto it = m_glyphs.find(key); it != m_glyphs.end()) { + return it->second; + } + return m_glyphs.emplace(key, rasterizeLocked(rawFont, glyphIndex, info.isColorFont)) + .first->second; +} + +AtlasGlyph GlyphAtlas::rasterizeLocked(const QRawFont &rawFont, + const uint32_t glyphIndex, + const bool isColorFont) const +{ + AtlasGlyph empty; + empty.isEmpty = true; + + const QRectF bbox = rawFont.boundingRect(glyphIndex); + if (bbox.isEmpty()) { + return empty; // e.g. space + } + + // NOTE: hinting can push pixels outside the outline bbox, + // so render with slack and crop to the actual ink. + constexpr int SLACK = 2; + const int left = static_cast(std::floor(bbox.left())) - SLACK; + const int top = static_cast(std::floor(bbox.top())) - SLACK; + const int w = static_cast(std::ceil(bbox.right())) - left + SLACK; + const int h = static_cast(std::ceil(bbox.bottom())) - top + SLACK; + if (w <= 0 || h <= 0) { + return empty; + } + + QImage img(w, h, QImage::Format_ARGB32_Premultiplied); + img.fill(Qt::transparent); + { + QGlyphRun run; + run.setRawFont(rawFont); + run.setGlyphIndexes({glyphIndex}); + run.setPositions({QPointF(0, 0)}); + + QPainter painter(&img); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setRenderHint(QPainter::TextAntialiasing, true); + // mono glyphs come out premultiplied white (a,a,a,a); + // color fonts ignore the pen + painter.setPen(Qt::white); + painter.drawGlyphRun(QPointF(-left, -top), run); + } + + int minX = w; + int minY = h; + int maxX = -1; + int maxY = -1; + bool isColor = false; + for (int y = 0; y < h; ++y) { + const QRgb *const row = reinterpret_cast(img.constScanLine(y)); + for (int x = 0; x < w; ++x) { + const QRgb px = row[x]; + const int a = qAlpha(px); + if (a == 0) { + continue; + } + minX = std::min(minX, x); + maxX = std::max(maxX, x); + minY = std::min(minY, y); + maxY = std::max(maxY, y); + // NOTE: color fonts still have monochrome glyphs (e.g. digits), + // which render in the pen color and must stay tintable. + if (isColorFont && (qRed(px) != a || qGreen(px) != a || qBlue(px) != a)) { + isColor = true; + } + } + } + if (maxX < 0) { + return empty; + } + + const int inkW = maxX - minX + 1; + const int inkH = maxY - minY + 1; + const int cellW = inkW + 2 * PADDING; + const int cellH = inkH + 2 * PADDING; + + const std::optional cell = allocateLocked(cellW, cellH); + if (!cell) { + return empty; + } + + // straight alpha for BlendModeEnum::TRANSPARENCY + const QImage ink = img.copy(minX, minY, inkW, inkH).convertToFormat(QImage::Format_RGBA8888); + for (int y = 0; y < inkH; ++y) { + const uchar *const src = ink.constScanLine(y); + uchar *const dst = m_image.scanLine(cell->y() + PADDING + y) + (cell->x() + PADDING) * 4; + std::memcpy(dst, src, static_cast(inkW) * 4); + if (!isColor) { + // coverage in alpha; white RGB so the shader can tint + for (int x = 0; x < inkW; ++x) { + dst[x * 4 + 0] = 0xFF; + dst[x * 4 + 1] = 0xFF; + dst[x * 4 + 2] = 0xFF; + } + } + } + markDirtyLocked(QRect(*cell, QSize(cellW, cellH))); + + AtlasGlyph g; + g.x = cell->x(); + g.y = cell->y(); + g.width = cellW; + g.height = cellH; + g.xoffset = (left + minX) - PADDING; + // ink bottom is (top + maxY + 1) in y-down; flip to y-up + g.yoffset = -(top + maxY + 1) - PADDING; + g.isColor = isColor; + g.isEmpty = false; + return g; +} + +std::optional GlyphAtlas::allocateLocked(const int width, const int height) const +{ + for (;;) { + const int atlasW = m_image.width(); + const int atlasH = m_image.height(); + + if (m_shelf.x + width > atlasW) { + // start a new shelf + m_shelf.y += m_shelf.height; + m_shelf.x = 0; + m_shelf.height = 0; + } + + if (m_shelf.y + height > atlasH || width > atlasW) { + if (!growLocked()) { + if (!m_full) { + m_full = true; + qWarning() << "Font atlas is full at" << atlasW << "x" << atlasH + << "; further new glyphs will not be displayed."; + } + return std::nullopt; + } + continue; + } + + const QPoint result{m_shelf.x, m_shelf.y}; + m_shelf.x += width; + m_shelf.height = std::max(m_shelf.height, height); + return result; + } +} + +bool GlyphAtlas::growLocked() const +{ + const int oldSize = m_image.width(); + if (oldSize >= m_maxSize) { + return false; + } + const int newSize = std::min(m_maxSize, oldSize * 2); + + QImage bigger(newSize, newSize, QImage::Format_RGBA8888); + bigger.fill(Qt::transparent); + for (int y = 0; y < m_image.height(); ++y) { + std::memcpy(bigger.scanLine(y), m_image.constScanLine(y), static_cast(oldSize) * 4); + } + m_image = std::move(bigger); + + // NOTE: glyph rects are texel coords, so they stay valid; + // only the texture needs to be reallocated. + m_resized = true; + m_dirty = m_image.rect(); + return true; +} + +void GlyphAtlas::markDirtyLocked(const QRect &rect) const +{ + m_dirty = m_dirty.isNull() ? rect : m_dirty.united(rect); +} + +std::optional GlyphAtlas::takePendingUpload() const +{ + const std::lock_guard lock(m_mutex); + if (!m_resized && m_dirty.isNull()) { + return std::nullopt; + } + + AtlasUpload upload; + upload.resized = m_resized; + upload.atlasWidth = m_image.width(); + upload.atlasHeight = m_image.height(); + upload.rect = m_resized ? m_image.rect() : m_dirty; + // copy() is tightly packed (RGBA8 rows are 4-byte aligned) + upload.image = m_image.copy(upload.rect); + + m_resized = false; + m_dirty = QRect(); + return upload; +} + +QSize GlyphAtlas::getSize() const +{ + const std::lock_guard lock(m_mutex); + return m_image.size(); +} + +bool GlyphAtlas::isFull() const +{ + const std::lock_guard lock(m_mutex); + return m_full; +} + +QImage GlyphAtlas::copyImage() const +{ + const std::lock_guard lock(m_mutex); + return m_image.copy(); +} + +size_t GlyphAtlas::getNumGlyphs() const +{ + const std::lock_guard lock(m_mutex); + return m_glyphs.size(); +} + +} // namespace font diff --git a/src/font/GlyphAtlas.h b/src/font/GlyphAtlas.h new file mode 100644 index 000000000..0b45a87f3 --- /dev/null +++ b/src/font/GlyphAtlas.h @@ -0,0 +1,162 @@ +#pragma once +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2025 The MMapper Authors + +#include "../global/RuleOf5.h" +#include "../global/macros.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +class QRawFont; + +namespace font { + +struct NODISCARD AtlasGlyph final +{ + // atlas texel rect, upper left origin; includes PADDING on every side + int x = 0; + int y = 0; + int width = 0; + int height = 0; + // quad offset from the pen: x to the right, y UP from the baseline + int xoffset = 0; + int yoffset = 0; + // color glyphs (emoji) are straight RGBA; otherwise RGB is white and A is coverage + bool isColor = false; + // no ink (e.g. space), or didn't fit; don't draw + bool isEmpty = true; +}; + +struct NODISCARD ShapedGlyph final +{ + AtlasGlyph glyph; + int x = 0; // pen offset from the start of the string +}; + +struct NODISCARD ShapedText final +{ + std::vector glyphs; + int advance = 0; +}; + +// atlas pixels the GL texture doesn't have yet +struct NODISCARD AtlasUpload final +{ + QImage image; // RGBA8888, straight alpha; a copy of `rect` + QRect rect; + // the atlas grew, so the texture must be reallocated (rect covers everything) + bool resized = false; + int atlasWidth = 0; + int atlasHeight = 0; +}; + +// Glyphs are shaped with QTextLayout and rasterized on demand into a +// shelf-packed RGBA8 image that doubles as needed (up to maxSize). +// +// NOTE: The cache is logically const, so every method is const. Batch +// generation calls shape() from worker threads; the GL thread calls +// takePendingUpload(). Everything mutable is guarded by m_mutex. +class NODISCARD GlyphAtlas final +{ +public: + // transparent texels around each glyph, so linear filtering can't bleed + static constexpr int PADDING = 1; + static constexpr int MIN_SIZE = 256; + + struct NODISCARD Config final + { + QFont font; + int initialSize = MIN_SIZE; + // NOTE: ES 3.0 / WebGL 2 only guarantee GL_MAX_TEXTURE_SIZE >= 2048. + int maxSize = 2048; + }; + +private: + struct NODISCARD Shelf final + { + int x = 0; + int y = 0; + int height = 0; + }; + + mutable std::mutex m_mutex; + const QFont m_font; + const int m_maxSize; + int m_lineHeight = 0; + int m_ascent = 0; + + mutable QImage m_image; + mutable Shelf m_shelf; + mutable bool m_full = false; + + struct NODISCARD FontInfo final + { + uint32_t id = 0; + bool isColorFont = false; + }; + mutable QHash m_fonts; // family|style (fallback fonts) + mutable std::unordered_map m_glyphs; + mutable std::unordered_map m_shaped; + + AtlasGlyph m_background; + AtlasGlyph m_underline; + + mutable QRect m_dirty; + mutable bool m_resized = false; + +public: + explicit GlyphAtlas(const Config &config); + ~GlyphAtlas(); + DELETE_CTORS_AND_ASSIGN_OPS(GlyphAtlas); + +public: + NODISCARD const QFont &getFont() const { return m_font; } + NODISCARD int getLineHeight() const { return m_lineHeight; } + NODISCARD int getAscent() const { return m_ascent; } + NODISCARD int getMaxSize() const { return m_maxSize; } + // solid white blocks, so background and underline draw in the same batch + NODISCARD const AtlasGlyph &getBackground() const { return m_background; } + NODISCARD const AtlasGlyph &getUnderline() const { return m_underline; } + +public: + // single line; rasterizes any glyphs not already in the atlas + NODISCARD ShapedText shape(std::string_view utf8) const; + NODISCARD int getAdvance(std::string_view utf8) const { return shape(utf8).advance; } + +public: + // GL thread: what changed since the last call, if anything + NODISCARD std::optional takePendingUpload() const; + NODISCARD QSize getSize() const; + // hit maxSize; new glyphs are being dropped + NODISCARD bool isFull() const; + NODISCARD QImage copyImage() const; + NODISCARD size_t getNumGlyphs() const; + +private: + // caller must hold m_mutex + NODISCARD ShapedText shapeLocked(std::string_view utf8) const; + NODISCARD const AtlasGlyph &lookupOrRasterizeLocked(const QRawFont &rawFont, + uint32_t glyphIndex) const; + NODISCARD AtlasGlyph rasterizeLocked(const QRawFont &rawFont, + uint32_t glyphIndex, + bool isColorFont) const; + NODISCARD std::optional allocateLocked(int width, int height) const; + NODISCARD bool growLocked() const; + void markDirtyLocked(const QRect &rect) const; +}; + +NODISCARD int pointSizeToPhysicalPixels(int pointSize, float logicalDpi, float devicePixelRatio); + +} // namespace font diff --git a/src/global/ConfigConsts-Computed.h b/src/global/ConfigConsts-Computed.h index 14d66b409..03c684cfd 100644 --- a/src/global/ConfigConsts-Computed.h +++ b/src/global/ConfigConsts-Computed.h @@ -59,6 +59,17 @@ static inline constexpr PlatformEnum CURRENT_PLATFORM = std::invoke([]() constex #endif }); +// NOTE: Qt reports 72 dpi on mac but 96 everywhere else, so the same point +// size renders 3/4 as large on mac. Use this for default font sizes. +NODISCARD static inline constexpr int platformPointSize(const int pointSizeAt96Dpi) +{ + if constexpr (CURRENT_PLATFORM == PlatformEnum::Mac) { + return (pointSizeAt96Dpi * 4 + 1) / 3; // rounded + } else { + return pointSizeAt96Dpi; + } +} + static inline constexpr EnvironmentEnum CURRENT_ENVIRONMENT = std::invoke( []() constexpr -> EnvironmentEnum { #if Q_PROCESSOR_WORDSIZE == 4 diff --git a/src/global/window_utils.cpp b/src/global/window_utils.cpp index b46dcf221..7a276897d 100644 --- a/src/global/window_utils.cpp +++ b/src/global/window_utils.cpp @@ -5,6 +5,8 @@ #include "utils.h" +#include +#include #include #include #include @@ -94,3 +96,18 @@ void mmqt::popupMenu(std::unique_ptr menu, const QPoint &globalPos) raw->setAttribute(Qt::WA_DeleteOnClose); raw->popup(globalPos); } + +QFontDialog &mmqt::showFontDialog(QWidget *const parent, + const QFont &initial, + const QString &title, + const QFontDialog::FontDialogOptions options, + std::function onAccepted) +{ + auto *const dialog = new QFontDialog(initial, parent); + dialog->setWindowTitle(title); + dialog->setOptions(options | QFontDialog::DontUseNativeDialog); + dialog->setAttribute(Qt::WA_DeleteOnClose); + QObject::connect(dialog, &QFontDialog::fontSelected, dialog, std::move(onAccepted)); + dialog->open(); + return *dialog; +} diff --git a/src/global/window_utils.h b/src/global/window_utils.h index 2073e1770..0baf8de2e 100644 --- a/src/global/window_utils.h +++ b/src/global/window_utils.h @@ -2,8 +2,12 @@ // SPDX-License-Identifier: GPL-2.0-or-later // Copyright (C) 2024 The MMapper Authors +#include #include +#include + +class QFont; class QMenu; class QMessageBox; class QObject; @@ -28,4 +32,12 @@ extern QMessageBox &showCritical(QWidget *parent, const QString &title, const QS // Non-blocking replacement for QMenu::exec(): the menu is shown at a global // position and deletes itself when closed. extern void popupMenu(std::unique_ptr menu, const QPoint &globalPos); +// Non-blocking replacement for QFontDialog::getFont(): onAccepted is only +// called if the user picks a font. Always uses Qt's own dialog, because the +// native mac font panel doesn't list fonts added with addApplicationFont(). +extern QFontDialog &showFontDialog(QWidget *parent, + const QFont &initial, + const QString &title, + QFontDialog::FontDialogOptions options, + std::function onAccepted); } // namespace mmqt diff --git a/src/mainwindow/mainwindow.cpp b/src/mainwindow/mainwindow.cpp index 8647333d4..2f8111d5b 100644 --- a/src/mainwindow/mainwindow.cpp +++ b/src/mainwindow/mainwindow.cpp @@ -88,6 +88,11 @@ NODISCARD static const char *basic_plural(size_t n) static void addApplicationFont() { + // the map's default font (see GLFont::init) + if (QFontDatabase::addApplicationFont(":/fonts/Cantarell-Regular.ttf") == -1) { + qWarning() << "Unable to load bundled Cantarell font"; + } + const auto id = QFontDatabase::addApplicationFont(":/fonts/DejaVuSansMono.ttf"); const auto family = QFontDatabase::applicationFontFamilies(id); if (family.isEmpty()) { @@ -96,12 +101,9 @@ static void addApplicationFont() // Use the application font here because we can guarantee that resources have been loaded. // REVISIT: Move this to the configuration? if (getConfig().integratedClient.font.isEmpty()) { - // TODO: Explain why mac is 20% larger. - static constinit int defaultFontSize = (CURRENT_PLATFORM == PlatformEnum::Mac) ? 12 - : 10; QFont defaultClientFont; defaultClientFont.setFamily(family.front()); - defaultClientFont.setPointSize(defaultFontSize); + defaultClientFont.setPointSize(platformPointSize(10)); defaultClientFont.setStyleStrategy(QFont::PreferAntialias); setConfig().integratedClient.font = defaultClientFont.toString(); } diff --git a/src/opengl/Font.cpp b/src/opengl/Font.cpp index fddbb6f83..7cdc3944c 100644 --- a/src/opengl/Font.cpp +++ b/src/opengl/Font.cpp @@ -6,38 +6,30 @@ #include "Font.h" #include "../configuration/configuration.h" -#include "../display/Filenames.h" -#include "../display/MapCanvasData.h" #include "../display/Textures.h" +#include "../font/GlyphAtlas.h" #include "../global/ConfigConsts.h" -#include "../global/hash.h" #include "../global/utils.h" #include "FontFormatFlags.h" #include "OpenGL.h" +#include #include -#include -#include +#include #include #include #include -#include #include #include #include #include +#include +#include #include #include -static const bool VERBOSE_FONT_DEBUG = std::invoke([]() -> bool { - if (auto opt = utils::getEnvBool("MMAPPER_VERBOSE_FONT_DEBUG")) { - return opt.value(); - } - return false; -}); - // NOTE: Rect doesn't actually include the hi value. struct NODISCARD Rect final { @@ -49,281 +41,25 @@ struct NODISCARD Rect final NODISCARD glm::ivec2 size() const { return {width(), height()}; } }; -NODISCARD static bool intersects(const Rect &a, const Rect &b) -{ -#define OVERLAPS(_xy) ((a.lo._xy) < (b.hi._xy) && (b.lo._xy) < (a.hi._xy)) - return OVERLAPS(x) && OVERLAPS(y); -#undef OVERLAPS -} - -using IntPair = std::pair; - -template<> -struct std::hash -{ - std::size_t operator()(const IntPair &ip) const noexcept - { -#define CAST(i) static_cast(static_cast(i)) - return numeric_hash(CAST(ip.first) | (CAST(ip.second) << 32u)); -#undef CAST - } -}; - +// NOTE: shared with the worker threads that generate text meshes. struct NODISCARD FontMetrics final { - static constexpr int UNDERLINE_ID = -257; - static constexpr int BACKGROUND_ID = -258; - - struct NODISCARD Glyph final - { - int id = 0; - int x = 0; - int y = 0; - int width = 0; - int height = 0; - int xoffset = 0; - int yoffset = 0; - int xadvance = 0; - - Glyph() = default; - ~Glyph() = default; - DEFAULT_CTORS_AND_ASSIGN_OPS(Glyph); - - // used by most cases - explicit Glyph(const int id_, - const int x_, - const int y_, - const int width_, - const int height_, - const int xoffset_, - const int yoffset_, - const int xadvance_) - : id{id_} - , x{x_} - , y{y_} - , width{width_} - , height{height_} - , xoffset{xoffset_} - , yoffset{yoffset_} - , xadvance{xadvance_} - {} - - // used for underline - explicit Glyph(const int id_, - const int x_, - const int y_, - const int width_, - const int height_, - const int xoffset_, - const int yoffset_) - : id{id_} - , x{x_} - , y{y_} - , width{width_} - , height{height_} - , xoffset{xoffset_} - , yoffset{yoffset_} - {} - - // used for background - explicit Glyph(const int id_, const int x_, const int y_, const int width_, const int height_) - : id{id_} - , x{x_} - , y{y_} - , width{width_} - , height{height_} - {} - - NODISCARD glm::ivec2 getPosition() const { return glm::ivec2{x, y}; } - NODISCARD glm::ivec2 getSize() const { return glm::ivec2{width, height}; } - NODISCARD glm::ivec2 getOffset() const { return glm::ivec2{xoffset, yoffset}; } - - NODISCARD Rect getRect() const - { - const auto lo = getPosition(); - return Rect{lo, lo + getSize()}; - } - }; - - /// In this - /// forum post, the Angelcode BMFont author "WitchLord" says: For example, the kerning pair for the - /// letters A and T is usually a negative value to make the characters display a bit closer together, while the - /// kerning pair for the letters A and M is usually a positive value. - /// - /// We know that that BMFont generates `Kerning 65 (aka "A") 84 (aka "T") -1` for `:/fonts/DejaVuSans16.fnt`, - /// so the amount must be added to the advance / xoffset. - struct NODISCARD Kerning final - { - int first = 0; - int second = 0; - int amount = 0; - - Kerning() = default; - ~Kerning() = default; - DEFAULT_CTORS_AND_ASSIGN_OPS(Kerning); - - Kerning(const int first_, const int second_, const int amount_) - : first{first_} - , second{second_} - , amount{amount_} - {} - }; - struct NODISCARD Common final { int lineHeight = 0; int base = 0; - int scaleW = 0; - int scaleH = 0; - int marginX = 0; - int marginY = 0; + int marginX = 2; + int marginY = 1; }; - std::optional background; - std::optional underline; - + font::GlyphAtlas atlas; Common common; - // REVISIT: Since we only support latin-1, it might make sense to just have fixed - // size lookup tables such as array and array - // for an index into a vector of kernings, rather than using std::unordered_map. - std::vector raw_glyphs; - std::vector raw_kernings; - std::unordered_map glyphs; - std::unordered_map kernings; - - NODISCARD QString init(const QString &); - - NODISCARD const Glyph *lookupGlyph(const int i) const - { - const auto it = glyphs.find(i); - return (it == glyphs.end()) ? nullptr : it->second; - } - - NODISCARD const Glyph *lookupGlyph(const char c) const - { - return lookupGlyph(static_cast(static_cast(c))); - } - - NODISCARD const Glyph *getBackground() const - { - return background ? &background.value() : nullptr; - } - NODISCARD const Glyph *getUnderline() const { return underline ? &underline.value() : nullptr; } - - NODISCARD bool tryAddBackgroundGlyph(QImage &img) - { - const int w = common.scaleW; - const int h = common.scaleH; - - // must not overlap underline - const Rect ourGlyph{{w - 4, 0}, {w, 4}}; - - for (const Glyph &glyph : raw_glyphs) { - if (intersects(glyph.getRect(), ourGlyph)) { - qWarning() << "Glyph" << glyph.id << "overlaps expected background location"; - return false; - } - } - - if (VERBOSE_FONT_DEBUG) { - qDebug() << "Adding background glyph"; - } - // glyph location uses lower left origin - background.emplace(BACKGROUND_ID, common.scaleW - 3, 1, 2, 2); - - // note: the current image still uses UPPER left origin, - // but it will be flipped after this function. - for (int dy = -4; dy < 0; ++dy) { - for (int dx = -4; dx < 0; ++dx) { - img.setPixelColor(w + dx, h + dy, Qt::white); - } - } - return true; - } - - NODISCARD bool tryAddUnderlineGlyph(QImage &img) + explicit FontMetrics(const font::GlyphAtlas::Config &config) + : atlas{config} { - const int w = common.scaleW; - const int h = common.scaleH; - const Rect ourGlyph{{w - 12, 0}, {w - 8, 4}}; - - // must not overlap background - for (const Glyph &glyph : raw_glyphs) { - if (intersects(glyph.getRect(), ourGlyph)) { - qWarning() << "Glyph" << glyph.id << "overlaps expected underline location"; - return false; - } - } - - if (VERBOSE_FONT_DEBUG) { - qDebug() << "Adding underline glyph"; - } - // glyph location uses lower left origin - underline.emplace(UNDERLINE_ID, common.scaleW - 11, 1, 2, 1, 0, -1); - - // note: the current image still uses UPPER left origin, - // but it will be flipped after this function. - for (int dy = -4; dy < 0; ++dy) { - const auto color = (dy == -2) ? QColor{Qt::white} : QColor(0, 0, 0, 0); - for (int dx = -12; dx < -8; ++dx) { - img.setPixelColor(w + dx, h + dy, color); - } - } - return true; - } - - void tryAddSyntheticGlyphs(QImage &img) - { - if (img.width() != common.scaleW || img.height() != common.scaleH) { - qWarning() << "Image is the wrong size"; - return; - } - - std::ignore = tryAddBackgroundGlyph(img); - std::ignore = tryAddUnderlineGlyph(img); - } - - NODISCARD const Kerning *lookupKerning(const Glyph *const prev, const Glyph *const current) const - { - if (prev == nullptr || current == nullptr) { - return nullptr; - } - - const auto it = kernings.find(IntPair{prev->id, current->id}); - return (it == kernings.end()) ? nullptr : it->second; - } - - template - void foreach_glyph(const std::string_view msg, EmitGlyph &&emitGlyph) const - { - const Glyph *prev = nullptr; - for (const char &c : msg) { - const Glyph *const current = lookupGlyph(c); - if (current != nullptr) { - emitGlyph(current, lookupKerning(prev, current)); - prev = current; - } else if (auto oops = lookupGlyph(char_consts::C_QUESTION_MARK)) { - qWarning() << "Unable to lookup glyph" << QString(QChar(c)); - emitGlyph(oops, lookupKerning(prev, oops)); - prev = oops; - } else { - prev = nullptr; - } - } - } - - NODISCARD int measureWidth(const std::string_view msg) const - { - int width = 0; - foreach_glyph(msg, [&width](const Glyph *const g, const Kerning *const k) { - width += g->xadvance; - if (k != nullptr) { - // kerning amount is added to the advance - width += k->amount; - } - }); - return width; + common.lineHeight = atlas.getLineHeight(); + common.base = atlas.getAscent(); } void getFontBatchRawData(const GLText *text, @@ -339,147 +75,6 @@ void getFontBatchRawData(const FontMetrics &fm, fm.getFontBatchRawData(text, count, output); } -struct NODISCARD PrintedChar final -{ - int id = 0; -}; - -QDebug operator<<(QDebug os, PrintedChar c); -QDebug operator<<(QDebug os, PrintedChar c) -{ - os.nospace(); - os << c.id << " (aka " << QString(QChar(c.id)) << ")"; - os.space(); - return os; -} - -QString FontMetrics::init(const QString &fontFilename) -{ - qInfo() << "Loading font from " << fontFilename; - - QFile f(fontFilename); - if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { - qCritical() << "Unable to load font"; - abort(); - } - - raw_glyphs.reserve(256); - raw_kernings.reserve(1024); - - QFileInfo info(fontFilename); - QString imageFilename; - - bool hasCommon = false; - - QXmlStreamReader xml(&f); - while (!xml.atEnd() && !xml.hasError()) { - if (xml.readNextStartElement()) { - const auto &attr = xml.attributes(); - if (xml.name() == QStringLiteral("common")) { - if (hasCommon) { - assert(false); - continue; - } - hasCommon = true; - // - const int lineHeight = attr.value("lineHeight").toInt(); - const int base = attr.value("base").toInt(); - const int scaleW = attr.value("scaleW").toInt(); - const int scaleH = attr.value("scaleH").toInt(); - const int marginX = 2; - const int marginY = 1; - if (VERBOSE_FONT_DEBUG) { - qDebug() << "Common" << lineHeight << base << scaleW << scaleH << marginX - << marginY; - } - common = Common{lineHeight, base, scaleW, scaleH, marginX, marginY}; - - } else if (xml.name() == QStringLiteral("char")) { - if (!hasCommon) { - assert(false); - continue; - } - - if (attr.value("page").toInt() != 0 || attr.value("chnl").toInt() != 15) { - assert(false); - continue; - } - - // - const int id = attr.value("id").toInt(); - - const int x = attr.value("x").toInt(); - const int y = attr.value("y").toInt(); - const int width = attr.value("width").toInt(); - const int height = attr.value("height").toInt(); - const int xoffset = attr.value("xoffset").toInt(); - const int yoffset = attr.value("yoffset").toInt(); - const int xadvance = attr.value("xadvance").toInt(); - - // REVISIT: should these be offset by -1? - const int y2 = common.scaleH - (y + height); - const int yoffset2 = common.base - (yoffset + height); - - if (VERBOSE_FONT_DEBUG) { - qDebug() << "Glyph" << PrintedChar{id} << x << y << width << height << xoffset - << yoffset << xadvance << "--->" << y2 << yoffset2; - } - - raw_glyphs.emplace_back(id, x, y2, width, height, xoffset, yoffset2, xadvance); - - } else if (xml.name() == QStringLiteral("kerning")) { - if (!hasCommon) { - assert(false); - continue; - } - - // - const int first = attr.value("first").toInt(); - const int second = attr.value("second").toInt(); - const int amount = attr.value("amount").toInt(); - if (VERBOSE_FONT_DEBUG) { - qDebug() << "Kerning" << PrintedChar{first} << PrintedChar{second} << amount; - } - raw_kernings.emplace_back(first, second, amount); - } else if (xml.name() == QStringLiteral("page")) { - const int id = attr.value("id").toInt(); - if (id != 0) { - continue; - } - const auto &file = attr.value("file").toString(); - const auto &path = info.dir().canonicalPath() + "/" + file; - - const bool exists = QFile{path}.exists(); - if (exists) { - imageFilename = path; - } - - if (VERBOSE_FONT_DEBUG) { - QDebug &&os = qDebug(); - os << "page" << id << file; - os.nospace(); - os << "(aka " << path << ")"; - os.space(); - os << (!exists ? "Does not exist." : "Exists."); - } - } - } - } - - qInfo() << "Loaded" << raw_glyphs.size() << "glyphs and" << raw_kernings.size() << "kernings"; - - for (const Glyph &glyph : raw_glyphs) { - assert(isClamped(glyph.id, 0, 255)); - glyphs[glyph.id] = &glyph; - } - - for (const Kerning &kerning : raw_kernings) { - kernings[IntPair{kerning.first, kerning.second}] = &kerning; - } - - return imageFilename; -} - class NODISCARD FontBatchBuilder final { private: @@ -533,16 +128,17 @@ class NODISCARD FontBatchBuilder final private: const FontMetrics &m_fm; - const glm::ivec2 m_iTexSize{}; std::vector &m_verts3d; Opts m_opts; Bounds m_bounds; + font::ShapedText m_shaped; int m_xlinepos = 0; bool m_noOutput = false; void resetPerStringData() { m_opts.reset(); m_bounds = Bounds(); + m_shaped = font::ShapedText{}; m_xlinepos = 0; m_noOutput = false; } @@ -550,13 +146,14 @@ class NODISCARD FontBatchBuilder final public: explicit FontBatchBuilder(const FontMetrics &fm, std::vector &output) : m_fm{fm} - , m_iTexSize{fm.common.scaleW, fm.common.scaleH} , m_verts3d{output} {} - NODISCARD glm::vec2 getTexCoord(const glm::ivec2 iTexCoord) const + // NOTE: tex coords are atlas texels (upper left origin); the vertex shader + // divides by textureSize(), so the atlas can grow under existing meshes. + NODISCARD static glm::vec2 getTexCoord(const glm::ivec2 iTexCoord) { - return glm::vec2(iTexCoord) / glm::vec2(m_iTexSize); + return glm::vec2(iTexCoord); } // REVISIT: This could be done in the shader, @@ -575,30 +172,26 @@ class NODISCARD FontBatchBuilder final return pos; } - void emitGlyphQuad(const bool isEmpty, - const glm::ivec2 iVertex00, - const glm::ivec2 iTexCoord00, - const glm::ivec2 iglyphSize) + void emitGlyphQuad(const glm::ivec2 iVertex00, const font::AtlasGlyph &g) { - const auto emitWithOffset = - [this, isEmpty, &iVertex00, &iTexCoord00](const glm::ivec2 pixelOffset) -> void { + const auto emitWithOffset = [this, &iVertex00, &g](const glm::ivec2 pixelOffset) -> void { const glm::ivec2 relativeVertPos = iVertex00 + pixelOffset; - if (!isEmpty) { - // side-effect: updates bounds; this must come before return - m_bounds.include(relativeVertPos); - } + // side-effect: updates bounds; this must come before return + m_bounds.include(relativeVertPos); if (m_noOutput) { return; } - const glm::vec2 tc = getTexCoord(iTexCoord00 + pixelOffset); + // vertex y goes up, but atlas rows go down + const glm::ivec2 iTexCoord{g.x + pixelOffset.x, g.y + (g.height - pixelOffset.y)}; + const glm::vec2 tc = getTexCoord(iTexCoord); const glm::vec2 vert = transformVert(relativeVertPos); - m_verts3d.emplace_back(m_opts.pos, m_opts.fgColor, tc, vert); + m_verts3d.emplace_back(m_opts.pos, m_opts.fgColor, tc, vert, g.isColor ? 1.f : 0.f); }; - const auto &x = iglyphSize.x; - const auto &y = iglyphSize.y; + const auto &x = g.width; + const auto &y = g.height; // 3-2 // | | // 0-1 @@ -608,39 +201,28 @@ class NODISCARD FontBatchBuilder final emitWithOffset(glm::ivec2(0, y)); } - void emitGlyph(const FontMetrics::Glyph *const g, const FontMetrics::Kerning *const k) + void emitGlyphs(const int wordOffset, const bool output) { - assert(isClamped(g->id, 0, 255)); - const auto glyphSize = glm::ivec2(g->width, g->height); - const auto iTexCoord00 = glm::ivec2(g->x, g->y); - if (k != nullptr) { - // kerning amount is added to the advance - m_xlinepos += k->amount; - } - const auto iVertex00 = glm::ivec2(m_xlinepos + g->xoffset, g->yoffset); - m_xlinepos += g->xadvance; - emitGlyphQuad(std::isspace(g->id), iVertex00, iTexCoord00, glyphSize); - } - - void call_foreach_glyph(const int wordOffset, const bool output) - { - const auto emitGlyphLambda = [this](const FontMetrics::Glyph *const g, - const FontMetrics::Kerning *const k) { - emitGlyph(g, k); - }; - m_noOutput = !output; - m_xlinepos = wordOffset; - m_fm.foreach_glyph(m_opts.msg, emitGlyphLambda); + for (const font::ShapedGlyph &sg : m_shaped.glyphs) { + const font::AtlasGlyph &g = sg.glyph; + if (g.isEmpty) { + continue; + } + const glm::ivec2 iVertex00{wordOffset + sg.x + g.xoffset, g.yoffset}; + emitGlyphQuad(iVertex00, g); + } + m_xlinepos = wordOffset + m_shaped.advance; } void addString(const GLText &text) { resetPerStringData(); this->m_opts = Opts{text}; + m_shaped = m_fm.atlas.shape(m_opts.msg); int wordOffset = 0; - call_foreach_glyph(wordOffset, false); + emitGlyphs(wordOffset, false); // measurement, background color, and underline. { @@ -660,6 +242,11 @@ class NODISCARD FontBatchBuilder final #undef ADD }; + const auto texRect = [](const font::AtlasGlyph &g) -> Rect { + const glm::ivec2 lo{g.x, g.y}; + return Rect{lo, lo + glm::ivec2{g.width, g.height}}; + }; + const glm::ivec2 margin{m_fm.common.marginX, m_fm.common.marginY}; const auto &lo = m_bounds.minVertPos; const auto &hi = m_bounds.maxVertPos; @@ -676,120 +263,181 @@ class NODISCARD FontBatchBuilder final } if (m_opts.optBgColor) { - if (const FontMetrics::Glyph *const background = m_fm.getBackground()) { - quad(m_opts.optBgColor.value(), - Rect{lo - margin, hi + margin}, - background->getRect()); - } + // bounds include the glyph padding; shrink back to the ink + const glm::ivec2 pad{font::GlyphAtlas::PADDING}; + quad(m_opts.optBgColor.value(), + Rect{lo + pad - margin, hi - pad + margin}, + texRect(m_fm.atlas.getBackground())); } if (m_opts.wantUnderline) { - if (const FontMetrics::Glyph *const underline = m_fm.getUnderline()) { - const auto usize = underline->getSize(); - const auto offset = underline->getOffset() + glm::ivec2{wordOffset, 0}; - quad(m_opts.fgColor, - Rect{offset, offset + glm::ivec2{m_xlinepos, usize.y}}, - underline->getRect()); - } + const font::AtlasGlyph &underline = m_fm.atlas.getUnderline(); + const glm::ivec2 offset{wordOffset + underline.xoffset, underline.yoffset}; + quad(m_opts.fgColor, + Rect{offset, offset + glm::ivec2{m_xlinepos, underline.height}}, + texRect(underline)); } } // note: 2nd call includes wordOffset that can be modified // above if caller requested HALIGN_CENTER or HALIGN_RIGHT. - call_foreach_glyph(wordOffset, true); + emitGlyphs(wordOffset, true); } }; +void FontMetrics::getFontBatchRawData(const GLText *const text, + const size_t count, + std::vector &output) const +{ + if (count == 0) { + return; + } + + const auto before = output.size(); + const auto end = text + count; + + // upper bound: utf8 byte count >= glyph count + const size_t maxExpectedVerts = std::invoke([text, end]() -> size_t { + size_t numQuads = 0; + for (const GLText *it = text; it != end; ++it) { + numQuads += it->text.size() + (it->bgcolor.has_value() ? 1 : 0) + + (it->fontFormatFlag.contains(FontFormatFlagEnum::UNDERLINE) ? 1 : 0); + } + return 4 * numQuads; + }); + + output.reserve(before + maxExpectedVerts); + + FontBatchBuilder fontBatchBuilder{*this, output}; + for (const GLText *it = text; it != end; ++it) { + fontBatchBuilder.addString(*it); + } + assert(output.size() <= before + maxExpectedVerts); +} + GLFont::GLFont(OpenGL &gl) : m_gl(gl) {} GLFont::~GLFont() = default; -NODISCARD static QString getFontFilename(const float devicePixelRatio) +NODISCARD static int getMaxTextureSize() { - const char *const FONT_KEY = "MMAPPER_FONT"; - const char *const font = "Cantarell"; - const char *const size = std::invoke([devicePixelRatio]() -> const char * { - if (devicePixelRatio > 1.75f) { - return "36"; - } - if (devicePixelRatio > 1.25f) { - return "27"; - } - return "18"; - }); - const QString fontFilename = QString(":/fonts/%1%2.fnt").arg(font).arg(size); - if (qEnvironmentVariableIsSet(FONT_KEY)) { - const QString tmp = qgetenv(FONT_KEY); - if (QFile{tmp}.exists()) { - qInfo() << "Using value from" << FONT_KEY << "to override font from" << fontFilename - << "to" << tmp; - return tmp; - } else { - qInfo() << "Path in" << FONT_KEY << "is invalid."; - } - } else if (IS_DEBUG_BUILD) { - qInfo() << "Note: You can override the font with" << FONT_KEY; + // NOTE: ES 3.0 / WebGL 2 only guarantee 2048; that's ~10k glyphs (16 MiB). + constexpr GLint DESIRED = 2048; + GLint maxSize = 0; + if (QOpenGLContext *const ctx = QOpenGLContext::currentContext()) { + ctx->functions()->glGetIntegerv(GL_MAX_TEXTURE_SIZE, &maxSize); } - - if (!QFile{fontFilename}.exists()) { - qWarning() << fontFilename << "does not exist."; + if (maxSize <= 0) { + return DESIRED; } - - return fontFilename; + return std::min(maxSize, DESIRED); } -void GLFont::init() +void GLFont::init(const float logicalDpi) { assert(m_gl.isRendererInitialized()); - auto pfm = std::make_shared(); - m_fontMetrics = pfm; - const auto fontFilename = getFontFilename(m_gl.getDevicePixelRatio()); - - auto &fm = *pfm; - const QString imageFilename = fm.init(fontFilename); - if (!QFile{imageFilename}.exists()) { - qWarning() << "invalid font filename" << imageFilename; - } + const auto &canvas = getConfig().canvas; + const QString family = canvas.mapFontFamily.get(); + const int pointSize = canvas.mapFontPointSize.get(); + const float dpr = m_gl.getDevicePixelRatio(); + m_logicalDpi = logicalDpi; + + // the font shader works in physical pixels, so rasterize at that size + const int pixelSize = font::pointSizeToPhysicalPixels(pointSize, logicalDpi, dpr); + QFont qfont(family); + qfont.setPixelSize(pixelSize); + // NOTE: Cantarell has no symbols or emoji. Prefer the platform's color + // emoji font (whichever exists); the bundled DejaVu Sans Mono then + // guarantees the monochrome symbol blocks (arrows, U+2600, dingbats). + qfont.setFamilies({family, + QStringLiteral("Apple Color Emoji"), + QStringLiteral("Segoe UI Emoji"), + QStringLiteral("Noto Color Emoji"), + QStringLiteral("DejaVu Sans Mono")}); + + font::GlyphAtlas::Config config; + config.font = qfont; + config.maxSize = getMaxTextureSize(); + + qInfo() << "Map font:" << family << pointSize << "pt =" << pixelSize << "px (dpi" << logicalDpi + << ", dpr" << dpr << ")"; + + m_fontMetrics = std::make_shared(config); if (m_texture) { m_texture->clearId(); } - // REVISIT: can this avoid switching to a different MMTexture object? + // NOTE: created empty; syncTexture() allocates and later grows it in place, + // so meshes holding this MMTexture stay valid. m_texture = MMTexture::alloc( QOpenGLTexture::Target::Target2D, - [&fm, &imageFilename](QOpenGLTexture &tex) -> void { - QImage img{imageFilename}; - fm.tryAddSyntheticGlyphs(img); - img = img.mirrored(); - - const QImage converted = img.convertToFormat(QImage::Format_RGBA8888); - tex.setFormat(QOpenGLTexture::TextureFormat::RGBA8_UNorm); + [](QOpenGLTexture &tex) -> void { tex.setMinMagFilters(QOpenGLTexture::Filter::Linear, QOpenGLTexture::Filter::Linear); + tex.setWrapMode(QOpenGLTexture::ClampToEdge); tex.setAutoMipMapGenerationEnabled(false); - tex.setMipLevels(0); - tex.setSize(converted.width(), converted.height()); - tex.allocateStorage(); - tex.setData(0, - QOpenGLTexture::PixelFormat::RGBA, - QOpenGLTexture::PixelType::UInt8, - converted.constBits()); }, true); - - // Each new MMTexture gets assigned the same old ID. - m_texture->setId(m_id); m_gl.setTextureLookup(m_id, m_texture); + + syncTexture(); +} + +void GLFont::syncTexture() +{ + if (!m_fontMetrics || !m_texture) { + return; + } + + const std::optional upload = m_fontMetrics->atlas.takePendingUpload(); + if (!upload) { + return; + } + + QOpenGLTexture &tex = deref(m_texture->get()); + const QImage &img = upload->image; + assert(img.format() == QImage::Format_RGBA8888); + + if (upload->resized || !tex.isStorageAllocated()) { + if (tex.isCreated()) { + tex.destroy(); + } + // RGBA8888 is byte-ordered (endian independent) and straight alpha + tex.setFormat(QOpenGLTexture::TextureFormat::RGBA8_UNorm); + tex.setMipLevels(1); + tex.setSize(upload->atlasWidth, upload->atlasHeight); + tex.allocateStorage(QOpenGLTexture::PixelFormat::RGBA, QOpenGLTexture::PixelType::UInt8); + tex.setMinMagFilters(QOpenGLTexture::Filter::Linear, QOpenGLTexture::Filter::Linear); + tex.setWrapMode(QOpenGLTexture::ClampToEdge); + tex.setAutoMipMapGenerationEnabled(false); + if (!tex.isStorageAllocated()) { + qWarning() << "Unable to allocate" << upload->atlasWidth << "x" << upload->atlasHeight + << "font atlas texture"; + return; + } + } + + assert(img.width() == upload->rect.width() && img.height() == upload->rect.height()); + tex.setData(upload->rect.x(), + upload->rect.y(), + 0, + upload->rect.width(), + upload->rect.height(), + 1, + QOpenGLTexture::PixelFormat::RGBA, + QOpenGLTexture::PixelType::UInt8, + img.constBits()); } void GLFont::cleanup() { m_fontMetrics.reset(); m_texture.reset(); + m_logicalDpi = 0.f; } int GLFont::getFontHeight() const @@ -799,10 +447,14 @@ int GLFont::getFontHeight() const std::optional GLFont::getGlyphAdvance(const char c) const { - if (const FontMetrics::Glyph *const g = getFontMetrics().lookupGlyph(c)) { - return g->xadvance; + if (c == '\0') { + return std::nullopt; } - return std::nullopt; + const auto shaped = getFontMetrics().atlas.shape(std::string_view{&c, 1}); + if (shaped.glyphs.empty()) { + return std::nullopt; + } + return shaped.advance; } glm::ivec2 GLFont::getScreenCenter() const @@ -810,36 +462,6 @@ glm::ivec2 GLFont::getScreenCenter() const return m_gl.getPhysicalViewport().offset + m_gl.getPhysicalViewport().size / 2; } -void FontMetrics::getFontBatchRawData(const GLText *const text, - const size_t count, - std::vector &output) const -{ - if (count == 0) { - return; - } - - const auto before = output.size(); - const auto end = text + count; - - const size_t expectedVerts = std::invoke([text, end]() -> size_t { - int numGlyphs = 0; - for (const GLText *it = text; it != end; ++it) { - numGlyphs += static_cast(it->text.size()) + (it->bgcolor.has_value() ? 1 : 0) - + (it->fontFormatFlag.contains(FontFormatFlagEnum::UNDERLINE) ? 1 : 0); - } - return 4 * static_cast(numGlyphs); - }); - - output.reserve(before + expectedVerts); - - auto &fm = *this; - FontBatchBuilder fontBatchBuilder{fm, output}; - for (const GLText *it = text; it != end; ++it) { - fontBatchBuilder.addString(*it); - } - assert(output.size() == before + expectedVerts); -} - void GLFont::render2dTextImmediate(const View text) { if (text.empty()) { @@ -867,6 +489,7 @@ void GLFont::render3dTextImmediate(const View rawVerts) return; } + syncTexture(); m_gl.renderFont3d(m_texture, rawVerts); } @@ -889,6 +512,8 @@ std::vector GLFont::getFontMeshIntermediate(const View text) UniqueMesh GLFont::getFontMesh(const View rawVerts) { + // the verts may reference glyphs added by a worker thread + syncTexture(); return m_gl.createFontMesh(m_texture, DrawModeEnum::QUADS, rawVerts); } @@ -896,11 +521,10 @@ void GLFont::renderTextCentered(const QString &text, const Color color, const std::optional bgcolor) { - // here we're converting to latin1 because we cannot display unicode codepoints above 255 const auto center = glm::vec2{getScreenCenter()}; render2dTextImmediate( std::vector{GLText{glm::vec3{center, 0.f}, - mmqt::toStdStringLatin1(text), // GL font is latin1 + mmqt::toStdStringUtf8(text), color, bgcolor, FontFormatFlags{FontFormatFlagEnum::HALIGN_CENTER}}}); diff --git a/src/opengl/Font.h b/src/opengl/Font.h index fab28dbc1..7919b45a6 100644 --- a/src/opengl/Font.h +++ b/src/opengl/Font.h @@ -55,6 +55,7 @@ class NODISCARD GLFont final SharedMMTexture m_texture; MMTextureId m_id = INVALID_MM_TEXTURE_ID; std::shared_ptr m_fontMetrics; + float m_logicalDpi = 0.f; public: explicit GLFont(OpenGL &gl); @@ -77,8 +78,14 @@ class NODISCARD GLFont final assert(m_id == INVALID_MM_TEXTURE_ID); m_id = id; } - void init(); + // NOTE: call cleanup() + init() again if the font, DPI, or DPR changes. + void init(float logicalDpi); void cleanup(); + NODISCARD float getLogicalDpi() const { return m_logicalDpi; } + +private: + // GL thread only + void syncTexture(); public: NODISCARD int getFontHeight() const; diff --git a/src/opengl/OpenGLTypes.h b/src/opengl/OpenGLTypes.h index 45f742d1a..301985100 100644 --- a/src/opengl/OpenGLTypes.h +++ b/src/opengl/OpenGLTypes.h @@ -116,15 +116,20 @@ struct NODISCARD FontVert3d final Color color; glm::vec2 tex{}; glm::vec2 vert{}; // screen space + // 0 = monochrome signed-distance-field glyph (tinted by `color`), + // 1 = full-color glyph (e.g. an emoji, sampled as-is from the texture). + float isColor = 0.f; explicit FontVert3d(const glm::vec3 base_, const Color color_, const glm::vec2 tex_, - const glm::vec2 vert_) + const glm::vec2 vert_, + const float isColor_ = 0.f) : base{base_} , color{color_} , tex{tex_} , vert{vert_} + , isColor{isColor_} {} }; diff --git a/src/opengl/legacy/FontMesh3d.h b/src/opengl/legacy/FontMesh3d.h index e64e86f74..b22ae67df 100644 --- a/src/opengl/legacy/FontMesh3d.h +++ b/src/opengl/legacy/FontMesh3d.h @@ -44,17 +44,20 @@ class NODISCARD SimpleFont3dMesh : public SimpleMesh static_assert(sizeof(std::declval().color) == 4 * sizeof(uint8_t)); static_assert(sizeof(std::declval().tex) == 2 * sizeof(GLfloat)); static_assert(sizeof(std::declval().vert) == 2 * sizeof(GLfloat)); + static_assert(sizeof(std::declval().isColor) == 1 * sizeof(GLfloat)); Functions &gl = Base::m_functions; CHECK_ATTR_LOC(0, "aBase"); CHECK_ATTR_LOC(1, "aColor"); CHECK_ATTR_LOC(2, "aTexCoord"); CHECK_ATTR_LOC(3, "aVert"); + CHECK_ATTR_LOC(4, "aIsColor"); gl.enableAttrib(0, 3, GL_FLOAT, GL_FALSE, vertSize, VPO(base)); gl.enableAttrib(1, 4, GL_UNSIGNED_BYTE, GL_TRUE, vertSize, VPO(color)); gl.enableAttrib(2, 2, GL_FLOAT, GL_FALSE, vertSize, VPO(tex)); gl.enableAttrib(3, 2, GL_FLOAT, GL_FALSE, vertSize, VPO(vert)); + gl.enableAttrib(4, 1, GL_FLOAT, GL_FALSE, vertSize, VPO(isColor)); } }; diff --git a/src/preferences/clientpage.cpp b/src/preferences/clientpage.cpp index 758d8c2eb..3157dacab 100644 --- a/src/preferences/clientpage.cpp +++ b/src/preferences/clientpage.cpp @@ -7,6 +7,7 @@ #include "../configuration/configuration.h" #include "../global/ConfigConsts-Computed.h" #include "../global/macros.h" +#include "../global/window_utils.h" #include "ui_clientpage.h" #include @@ -210,20 +211,17 @@ void ClientPage::updateFontAndColors() void ClientPage::slot_onChangeFont() { - auto &fontDescription = setConfig().integratedClient.font; QFont oldFont; - oldFont.fromString(fontDescription); - - bool ok = false; - const QFont newFont = QFontDialog::getFont(&ok, - oldFont, - this, - "Select Font", - QFontDialog::MonospacedFonts); - if (ok) { - fontDescription = newFont.toString(); - updateFontAndColors(); - } + oldFont.fromString(getConfig().integratedClient.font); + + mmqt::showFontDialog(this, + oldFont, + "Select Font", + QFontDialog::MonospacedFonts, + [this](const QFont &newFont) { + setConfig().integratedClient.font = newFont.toString(); + updateFontAndColors(); + }); } void ClientPage::slot_onChangeBackgroundColor() diff --git a/src/preferences/graphicspage.cpp b/src/preferences/graphicspage.cpp index 00a053ae2..982e0f3ae 100644 --- a/src/preferences/graphicspage.cpp +++ b/src/preferences/graphicspage.cpp @@ -6,6 +6,7 @@ #include "../configuration/configuration.h" #include "../global/utils.h" +#include "../global/window_utils.h" #include "../opengl/OpenGLConfig.h" #include "AdvancedGraphics.h" #include "ui_graphicspage.h" @@ -100,6 +101,21 @@ GraphicsPage::GraphicsPage(QWidget *parent) this, &GraphicsPage::slot_drawUpperLayersTexturedStateChanged); + connect(ui->mapFontPushButton, &QAbstractButton::clicked, this, [this]() { + const auto &canvas = getConfig().canvas; + const QFont currentFont(canvas.mapFontFamily.get(), canvas.mapFontPointSize.get()); + mmqt::showFontDialog(this, + currentFont, + QStringLiteral("Select Map Font"), + {}, + [this](const QFont &newFont) { + setConfig().canvas.mapFontFamily.set(newFont.family()); + setConfig().canvas.mapFontPointSize.set(newFont.pointSize()); + syncMapFontButton(); + graphicsSettingsChanged(); + }); + }); + connect(ui->weatherAtmosphereSlider, &QSlider::valueChanged, this, [this](const int value) { setConfig().canvas.weatherAtmosphereIntensity.set(value); graphicsSettingsChanged(); @@ -172,6 +188,8 @@ void GraphicsPage::slot_loadConfig() ui->drawNotMappedExits->setChecked(settings.showUnmappedExits.get()); ui->drawDoorNames->setChecked(settings.drawDoorNames); + syncMapFontButton(); + ui->weatherAtmosphereSlider->setValue(settings.weatherAtmosphereIntensity.get()); ui->weatherPrecipitationSlider->setValue(settings.weatherPrecipitationIntensity.get()); ui->weatherTimeOfDaySlider->setValue(settings.weatherTimeOfDayIntensity.get()); @@ -195,6 +213,13 @@ void GraphicsPage::syncAntialiasingSamplesComboBox() : ""); } +void GraphicsPage::syncMapFontButton() +{ + const auto &canvas = getConfig().canvas; + ui->mapFontPushButton->setText( + QString("%1 %2pt").arg(canvas.mapFontFamily.get()).arg(canvas.mapFontPointSize.get())); +} + void GraphicsPage::changeColorClicked(XNamedColor &namedColor, QPushButton *const pushButton) { const QColor origColor = namedColor.getColor().getQColor(); diff --git a/src/preferences/graphicspage.h b/src/preferences/graphicspage.h index 6370c4320..5020c8972 100644 --- a/src/preferences/graphicspage.h +++ b/src/preferences/graphicspage.h @@ -32,6 +32,7 @@ class NODISCARD_QOBJECT GraphicsPage final : public QWidget private: void changeColorClicked(XNamedColor &color, QPushButton *pushButton); void syncAntialiasingSamplesComboBox(); + void syncMapFontButton(); void graphicsSettingsChanged() { emit sig_graphicsSettingsChanged(); } Ui::GraphicsPage *const ui; std::unique_ptr m_advanced; diff --git a/src/preferences/graphicspage.ui b/src/preferences/graphicspage.ui index a07fa56d0..6f72d96a8 100644 --- a/src/preferences/graphicspage.ui +++ b/src/preferences/graphicspage.ui @@ -30,7 +30,7 @@ 9 - + Qt::Orientation::Vertical @@ -43,44 +43,6 @@ - - - - Room Details - - - - 9 - - - 9 - - - 9 - - - 9 - - - 6 - - - - - Texture upper layers - - - - - - - Render hidden doors - - - - - - @@ -199,7 +161,98 @@ + + + + Map Font + + + + + + Font: + + + + + + + Select Font... + + + + + + + + + + Room Details + + + + 9 + + + 9 + + + 9 + + + 9 + + + 6 + + + + + Texture upper layers + + + + + + + Render hidden doors + + + + + + + + + Mapping Hints + + + + + + Show missing map ids + + + + + + + Show unmapped exits + + + + + + + Show unsaved changes + + + + + + + Colors @@ -291,37 +344,7 @@ - - - - Mapping Hints - - - - - - Show missing map ids - - - - - - - Show unmapped exits - - - - - - - Show unsaved changes - - - - - - - + Weather and Atmosphere @@ -408,7 +431,7 @@ - + Advanced Settings @@ -420,6 +443,7 @@ antialiasingSamplesComboBox trilinearFilteringCheckBox + mapFontPushButton drawUpperLayersTextured drawDoorNames drawUnsavedChanges diff --git a/src/resources/fonts/Cantarell-Regular.ttf b/src/resources/fonts/Cantarell-Regular.ttf new file mode 100644 index 000000000..535b58cf2 Binary files /dev/null and b/src/resources/fonts/Cantarell-Regular.ttf differ diff --git a/src/resources/fonts/Cantarell18.bmfc b/src/resources/fonts/Cantarell18.bmfc deleted file mode 100644 index 8083ba899..000000000 --- a/src/resources/fonts/Cantarell18.bmfc +++ /dev/null @@ -1,55 +0,0 @@ -# AngelCode Bitmap Font Generator configuration file -fileVersion=1 - -# font settings -fontName=Cantarell -fontFile= -charSet=0 -fontSize=18 -aa=4 -scaleH=100 -useSmoothing=1 -isBold=0 -isItalic=0 -useUnicode=1 -disableBoxChars=1 -outputInvalidCharGlyph=0 -dontIncludeKerningPairs=0 -useHinting=1 -renderFromOutline=1 -useClearType=1 - -# character alignment -paddingDown=0 -paddingUp=0 -paddingRight=0 -paddingLeft=0 -spacingHoriz=1 -spacingVert=1 -useFixedHeight=0 -forceZero=0 - -# output file -outWidth=256 -outHeight=256 -outBitDepth=32 -fontDescFormat=1 -fourChnlPacked=0 -textureFormat=png -textureCompression=0 -alphaChnl=0 -redChnl=4 -greenChnl=4 -blueChnl=4 -invA=0 -invR=0 -invG=0 -invB=0 - -# outline -outlineThickness=0 - -# selected chars -chars=32-126,160-255 - -# imported icon images diff --git a/src/resources/fonts/Cantarell18.fnt b/src/resources/fonts/Cantarell18.fnt deleted file mode 100644 index bf8589db7..000000000 --- a/src/resources/fonts/Cantarell18.fnt +++ /dev/null @@ -1,201 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/resources/fonts/Cantarell18_0.png b/src/resources/fonts/Cantarell18_0.png deleted file mode 100644 index 0b106e2ba..000000000 Binary files a/src/resources/fonts/Cantarell18_0.png and /dev/null differ diff --git a/src/resources/fonts/Cantarell27.bmfc b/src/resources/fonts/Cantarell27.bmfc deleted file mode 100644 index 34ffe77d4..000000000 --- a/src/resources/fonts/Cantarell27.bmfc +++ /dev/null @@ -1,55 +0,0 @@ -# AngelCode Bitmap Font Generator configuration file -fileVersion=1 - -# font settings -fontName=Cantarell -fontFile= -charSet=0 -fontSize=27 -aa=4 -scaleH=100 -useSmoothing=1 -isBold=0 -isItalic=0 -useUnicode=1 -disableBoxChars=1 -outputInvalidCharGlyph=0 -dontIncludeKerningPairs=0 -useHinting=1 -renderFromOutline=1 -useClearType=1 - -# character alignment -paddingDown=0 -paddingUp=0 -paddingRight=0 -paddingLeft=0 -spacingHoriz=1 -spacingVert=1 -useFixedHeight=0 -forceZero=0 - -# output file -outWidth=256 -outHeight=256 -outBitDepth=32 -fontDescFormat=1 -fourChnlPacked=0 -textureFormat=png -textureCompression=0 -alphaChnl=0 -redChnl=4 -greenChnl=4 -blueChnl=4 -invA=0 -invR=0 -invG=0 -invB=0 - -# outline -outlineThickness=0 - -# selected chars -chars=32-126,160-255 - -# imported icon images diff --git a/src/resources/fonts/Cantarell27.fnt b/src/resources/fonts/Cantarell27.fnt deleted file mode 100644 index b0440dc5b..000000000 --- a/src/resources/fonts/Cantarell27.fnt +++ /dev/null @@ -1,201 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/resources/fonts/Cantarell27_0.png b/src/resources/fonts/Cantarell27_0.png deleted file mode 100644 index 36a968365..000000000 Binary files a/src/resources/fonts/Cantarell27_0.png and /dev/null differ diff --git a/src/resources/fonts/Cantarell36.bmfc b/src/resources/fonts/Cantarell36.bmfc deleted file mode 100644 index 4020eb722..000000000 --- a/src/resources/fonts/Cantarell36.bmfc +++ /dev/null @@ -1,55 +0,0 @@ -# AngelCode Bitmap Font Generator configuration file -fileVersion=1 - -# font settings -fontName=Cantarell -fontFile= -charSet=0 -fontSize=36 -aa=4 -scaleH=100 -useSmoothing=1 -isBold=0 -isItalic=0 -useUnicode=1 -disableBoxChars=1 -outputInvalidCharGlyph=0 -dontIncludeKerningPairs=0 -useHinting=1 -renderFromOutline=1 -useClearType=1 - -# character alignment -paddingDown=0 -paddingUp=0 -paddingRight=0 -paddingLeft=0 -spacingHoriz=1 -spacingVert=1 -useFixedHeight=0 -forceZero=0 - -# output file -outWidth=256 -outHeight=256 -outBitDepth=32 -fontDescFormat=1 -fourChnlPacked=0 -textureFormat=png -textureCompression=0 -alphaChnl=0 -redChnl=4 -greenChnl=4 -blueChnl=4 -invA=0 -invR=0 -invG=0 -invB=0 - -# outline -outlineThickness=0 - -# selected chars -chars=32-126,160-255 - -# imported icon images diff --git a/src/resources/fonts/Cantarell36.fnt b/src/resources/fonts/Cantarell36.fnt deleted file mode 100644 index 56f9379c8..000000000 --- a/src/resources/fonts/Cantarell36.fnt +++ /dev/null @@ -1,201 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/resources/fonts/Cantarell36_0.png b/src/resources/fonts/Cantarell36_0.png deleted file mode 100644 index 36c64e798..000000000 Binary files a/src/resources/fonts/Cantarell36_0.png and /dev/null differ diff --git a/src/resources/mmapper2.qrc b/src/resources/mmapper2.qrc index 1854f1359..e91141126 100644 --- a/src/resources/mmapper2.qrc +++ b/src/resources/mmapper2.qrc @@ -6,12 +6,7 @@ LICENSE.OPENSSL LICENSE.QTKEYCHAIN emojis/short-codes.json - fonts/Cantarell18.fnt - fonts/Cantarell18_0.png - fonts/Cantarell27.fnt - fonts/Cantarell27_0.png - fonts/Cantarell36.fnt - fonts/Cantarell36_0.png + fonts/Cantarell-Regular.ttf fonts/DejaVuSansMono.ttf fonts/LICENSE icons/affect-bashed.png diff --git a/src/resources/shaders/legacy/font/frag.glsl b/src/resources/shaders/legacy/font/frag.glsl index bd4322425..c3282ce2d 100644 --- a/src/resources/shaders/legacy/font/frag.glsl +++ b/src/resources/shaders/legacy/font/frag.glsl @@ -1,14 +1,26 @@ // SPDX-License-Identifier: GPL-2.0-or-later // Copyright (C) 2019 The MMapper Authors +// The font atlas is a single straight-alpha RGBA texture holding two kinds +// of glyphs: +// - monochrome glyphs: RGB is opaque white and A is the antialiased +// coverage, so the texel is simply modulated by the text color. +// - color glyphs (e.g. emoji): RGBA holds the actual glyph color, sampled +// as-is and only faded by the text alpha. uniform sampler2D uFontTexture; in vec4 vColor; in vec2 vTexCoord; +flat in float vIsColor; out vec4 vFragmentColor; void main() { - vFragmentColor = vColor * texture(uFontTexture, vTexCoord); + vec4 texel = texture(uFontTexture, vTexCoord); + if (vIsColor > 0.5) { + vFragmentColor = vec4(texel.rgb, texel.a * vColor.a); + } else { + vFragmentColor = vColor * texel; + } } diff --git a/src/resources/shaders/legacy/font/vert.glsl b/src/resources/shaders/legacy/font/vert.glsl index 902f16b26..283741595 100644 --- a/src/resources/shaders/legacy/font/vert.glsl +++ b/src/resources/shaders/legacy/font/vert.glsl @@ -3,14 +3,19 @@ uniform mat4 uMVP3D; uniform ivec4 uPhysViewport; +// Only used for its size: texture coordinates arrive in atlas texels so +// the atlas can grow without invalidating meshes built earlier. +uniform sampler2D uFontTexture; layout(location = 0) in vec3 aBase; // address in world space layout(location = 1) in vec4 aColor; -layout(location = 2) in vec2 aTexCoord; +layout(location = 2) in vec2 aTexCoord; // atlas texels, upper-left origin layout(location = 3) in vec2 aVert; // offset in raw pixels +layout(location = 4) in float aIsColor; out vec4 vColor; out vec2 vTexCoord; +flat out float vIsColor; // [0, 1]^2 to pixels vec2 convertScreen01toPhysPixels(vec2 pos) @@ -87,6 +92,7 @@ vec4 computePosition() void main() { vColor = aColor; - vTexCoord = aTexCoord; + vTexCoord = aTexCoord / vec2(textureSize(uFontTexture, 0)); + vIsColor = aIsColor; gl_Position = computePosition(); } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a136b9f4d..ea907e5dc 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -23,6 +23,30 @@ if(MMAPPER_IS_DEBUG) endif() endif() +# Glyph atlas +set(font_SRCS + ../src/font/GlyphAtlas.cpp + ../src/font/GlyphAtlas.h +) +set(TestFont_SRCS TestFont.cpp) +add_executable(TestFont ${TestFont_SRCS} ${font_SRCS}) +add_dependencies(TestFont mm_test mm_global) +target_link_libraries(TestFont + mm_test + mm_global + Qt6::Gui + Qt6::Test + Qt6::Widgets + coverage_config) +set_target_properties( + TestFont PROPERTIES + CXX_STANDARD 20 + CXX_STANDARD_REQUIRED ON + CXX_EXTENSIONS OFF + COMPILE_FLAGS "${WARNING_FLAGS}" +) +add_test(NAME TestFont COMMAND TestFont) + # Common test library add_library(mm_test STATIC ../src/configuration/configuration.cpp diff --git a/tests/TestFont.cpp b/tests/TestFont.cpp new file mode 100644 index 000000000..3b257435d --- /dev/null +++ b/tests/TestFont.cpp @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2025 The MMapper Authors + +#include "../src/configuration/configuration.h" +#include "../src/font/GlyphAtlas.h" + +#include +#include + +class TestFont final : public QObject +{ + Q_OBJECT + +private: + QString m_family; + + NODISCARD QFont makeFont(const int pixelSize) const + { + QFont f(m_family); + f.setPixelSize(pixelSize); + return f; + } + +private slots: + void initTestCase() + { + setEnteredMain(); + const QString ttfPath = QFINDTESTDATA("../src/resources/fonts/Cantarell-Regular.ttf"); + QVERIFY(!ttfPath.isEmpty()); + const int fontId = QFontDatabase::addApplicationFont(ttfPath); + QVERIFY(fontId != -1); + m_family = QFontDatabase::applicationFontFamilies(fontId).at(0); + } + + void testPointSizeConversion() + { + // 11pt at 96 dpi is the ~15px em that master's baked Cantarell18 atlas had. + QCOMPARE(font::pointSizeToPhysicalPixels(11, 96.f, 1.f), 15); + QCOMPARE(font::pointSizeToPhysicalPixels(11, 96.f, 2.f), 29); + QCOMPARE(font::pointSizeToPhysicalPixels(12, 72.f, 1.f), 12); + // garbage in -> sane defaults + QCOMPARE(font::pointSizeToPhysicalPixels(11, 0.f, 0.f), 15); + QVERIFY(font::pointSizeToPhysicalPixels(0, 96.f, 1.f) >= 1); + } + + void testShapeBasics() + { + font::GlyphAtlas::Config config; + config.font = makeFont(15); + const font::GlyphAtlas atlas(config); + + QVERIFY(atlas.getLineHeight() > 0); + QVERIFY(atlas.getAscent() > 0); + QVERIFY(atlas.getAscent() <= atlas.getLineHeight()); + + const auto shaped = atlas.shape("Black Hill"); + QCOMPARE(shaped.glyphs.size(), static_cast(10)); + // The advance is what Qt's own layout says it is (same hinting). + const QFontMetrics fm(atlas.getFont()); + QCOMPARE(shaped.advance, fm.horizontalAdvance(QStringLiteral("Black Hill"))); + // ...and in the ballpark of master's baked Cantarell18 atlas (54px), + // to catch a gross scale regression. + QVERIFY2(shaped.advance >= 45 && shaped.advance <= 75, + qPrintable(QString::number(shaped.advance))); + + // Pen positions are monotonic for LTR text. + for (size_t i = 1; i < shaped.glyphs.size(); ++i) { + QVERIFY(shaped.glyphs[i].x >= shaped.glyphs[i - 1].x); + } + + // Space has no ink; letters do, and they carry padding. + QVERIFY(shaped.glyphs[5].glyph.isEmpty); + const auto &B = shaped.glyphs[0].glyph; + QVERIFY(!B.isEmpty); + QVERIFY(!B.isColor); + QVERIFY(B.width > 2 * font::GlyphAtlas::PADDING); + QVERIFY(B.height > 2 * font::GlyphAtlas::PADDING); + // 'B' sits on the baseline: quad bottom is one padding texel below it. + QCOMPARE(B.yoffset, -font::GlyphAtlas::PADDING); + // and its top is roughly the cap height + QVERIFY(B.yoffset + B.height >= 8); + QVERIFY(B.yoffset + B.height <= 14); + + // Distinct glyphs are rasterized once: B,l,a,c,k,H,i (l twice). + QCOMPARE(atlas.getNumGlyphs(), static_cast(8)); // includes the space + std::ignore = atlas.shape("Black Hill"); + QCOMPARE(atlas.getNumGlyphs(), static_cast(8)); + + // Empty and whitespace-only strings are harmless. + QCOMPARE(atlas.shape("").advance, 0); + QVERIFY(atlas.shape("").glyphs.empty()); + QVERIFY(atlas.shape(" ").advance > 0); + } + + void testAtlasPixels() + { + font::GlyphAtlas::Config config; + config.font = makeFont(15); + const font::GlyphAtlas atlas(config); + + const auto shaped = atlas.shape("H"); + const auto &g = shaped.glyphs.at(0).glyph; + QVERIFY(!g.isEmpty); + + const QImage img = atlas.copyImage(); + QCOMPARE(img.format(), QImage::Format_RGBA8888); + QCOMPARE(img.width(), font::GlyphAtlas::MIN_SIZE); + + // The synthetic white block backs the background/underline quads. + const auto &bg = atlas.getBackground(); + for (int y = bg.y; y < bg.y + bg.height; ++y) { + for (int x = bg.x; x < bg.x + bg.width; ++x) { + QCOMPARE(img.pixel(x, y), qRgba(255, 255, 255, 255)); + } + } + + // Padding ring is fully transparent, interior has white ink with coverage in alpha. + int opaque = 0; + for (int y = g.y; y < g.y + g.height; ++y) { + for (int x = g.x; x < g.x + g.width; ++x) { + const QRgb px = img.pixel(x, y); + const bool onRing = x < g.x + font::GlyphAtlas::PADDING + || x >= g.x + g.width - font::GlyphAtlas::PADDING + || y < g.y + font::GlyphAtlas::PADDING + || y >= g.y + g.height - font::GlyphAtlas::PADDING; + if (onRing) { + QCOMPARE(qAlpha(px), 0); + } else if (qAlpha(px) != 0) { + QCOMPARE(qRed(px), 255); + QCOMPARE(qGreen(px), 255); + QCOMPARE(qBlue(px), 255); + if (qAlpha(px) == 255) { + ++opaque; + } + } + } + } + // A hinted 'H' at 15px has solid stems, not just gray smear. + QVERIFY(opaque > 0); + } + + void testPendingUploads() + { + font::GlyphAtlas::Config config; + config.font = makeFont(15); + const font::GlyphAtlas atlas(config); + + // The initial (empty) atlas needs a full upload. + auto first = atlas.takePendingUpload(); + QVERIFY(first.has_value()); + QVERIFY(first->resized); + QCOMPARE(first->rect, QRect(0, 0, first->atlasWidth, first->atlasHeight)); + QCOMPARE(first->image.size(), first->rect.size()); + QVERIFY(!atlas.takePendingUpload().has_value()); + + // New glyphs only dirty the region they were packed into. + const auto shaped = atlas.shape("xyz"); + auto second = atlas.takePendingUpload(); + QVERIFY(second.has_value()); + QVERIFY(!second->resized); + QVERIFY(second->rect.width() < first->atlasWidth); + for (const auto &sg : shaped.glyphs) { + QVERIFY(second->rect.contains( + QRect(sg.glyph.x, sg.glyph.y, sg.glyph.width, sg.glyph.height))); + } + QCOMPARE(second->image.size(), second->rect.size()); + QCOMPARE(second->image.format(), QImage::Format_RGBA8888); + QVERIFY(!atlas.takePendingUpload().has_value()); + + // Re-shaping cached text uploads nothing. + std::ignore = atlas.shape("xyz"); + QVERIFY(!atlas.takePendingUpload().has_value()); + } + + void testGrowth() + { + font::GlyphAtlas::Config config; + config.font = makeFont(120); + config.initialSize = 256; + config.maxSize = 1024; + const font::GlyphAtlas atlas(config); + std::ignore = atlas.takePendingUpload(); + + const auto before = atlas.shape("AB"); + QCOMPARE(atlas.getSize(), QSize(256, 256)); + + // Enough 120px glyphs to overflow 256^2 several times. + const auto shaped = atlas.shape("CDEFGHIJKLMNOPQRSTUVWXYZ"); + QVERIFY(atlas.getSize().width() > 256); + QVERIFY(atlas.getSize().width() <= 1024); + QVERIFY(!atlas.isFull()); + + auto upload = atlas.takePendingUpload(); + QVERIFY(upload.has_value()); + QVERIFY(upload->resized); + QCOMPARE(upload->image.size(), atlas.getSize()); + + // Glyph rects survive growth unchanged (texel coordinates). + const auto after = atlas.shape("AB"); + QCOMPARE(after.glyphs.at(0).glyph.x, before.glyphs.at(0).glyph.x); + QCOMPARE(after.glyphs.at(0).glyph.y, before.glyphs.at(0).glyph.y); + for (const auto &sg : shaped.glyphs) { + QVERIFY(!sg.glyph.isEmpty); + QVERIFY(sg.glyph.x + sg.glyph.width <= atlas.getSize().width()); + QVERIFY(sg.glyph.y + sg.glyph.height <= atlas.getSize().height()); + } + } + + void testFullAtlasDegradesGracefully() + { + font::GlyphAtlas::Config config; + config.font = makeFont(200); + config.initialSize = 256; + config.maxSize = 256; + const font::GlyphAtlas atlas(config); + + const auto shaped = atlas.shape("ABCDEFGHIJ"); + QVERIFY(atlas.isFull()); + QCOMPARE(atlas.getSize(), QSize(256, 256)); + // Layout still works; glyphs that didn't fit are simply not drawn. + QVERIFY(shaped.advance > 0); + QCOMPARE(shaped.glyphs.size(), static_cast(10)); + bool anyEmpty = false; + for (const auto &sg : shaped.glyphs) { + anyEmpty |= sg.glyph.isEmpty; + } + QVERIFY(anyEmpty); + } + + void testUnicodeAndFallback() + { + font::GlyphAtlas::Config config; + config.font = makeFont(15); + const font::GlyphAtlas atlas(config); + + // Latin-1 and general punctuation shape to one glyph each. + QCOMPARE(atlas.shape("\xC3\xA9").glyphs.size(), static_cast(1)); // é + QCOMPARE(atlas.shape("\xE2\x80\x94").glyphs.size(), static_cast(1)); // em dash + + // Emoji go through font fallback. Whether a color font exists depends + // on the system, so only require that shaping doesn't blow up. + const auto emoji = atlas.shape("\xE2\x9A\xA1\xEF\xB8\x8F"); // U+26A1 U+FE0F + QVERIFY(!emoji.glyphs.empty()); + QVERIFY(emoji.advance > 0); + } +}; + +QTEST_MAIN(TestFont) +#include "TestFont.moc"