518 lines
19 KiB
C++
518 lines
19 KiB
C++
#include "render/TextRenderer.h"
|
||
#include <ft2build.h>
|
||
#include FT_FREETYPE_H
|
||
|
||
#include "Environment.h"
|
||
#include "render/OpenGlExtensions.h"
|
||
#include <iostream>
|
||
#include <array>
|
||
#include <algorithm>
|
||
#include <cmath>
|
||
#include <unordered_set>
|
||
|
||
namespace ZL {
|
||
|
||
struct GlyphAtlasData {
|
||
std::unordered_map<uint32_t, GlyphInfo> glyphs;
|
||
std::shared_ptr<Texture> atlasTexture;
|
||
size_t atlasWidth = 0;
|
||
size_t atlasHeight = 0;
|
||
float lineHeight = 32.f;
|
||
};
|
||
static std::unordered_map<std::string, GlyphAtlasData> s_glyphAtlasCache;
|
||
|
||
// Decode one UTF-8 codepoint from str at byte position i; advances i past the sequence.
|
||
static uint32_t nextUtf8Codepoint(const std::string& str, size_t& i)
|
||
{
|
||
auto b = [&](size_t offset) { return static_cast<unsigned char>(str[i + offset]); };
|
||
unsigned char c = b(0);
|
||
uint32_t cp;
|
||
size_t bytes;
|
||
if (c < 0x80) { cp = c; bytes = 1; }
|
||
else if (c < 0xE0) { cp = c & 0x1F; bytes = 2; }
|
||
else if (c < 0xF0) { cp = c & 0x0F; bytes = 3; }
|
||
else { cp = c & 0x07; bytes = 4; }
|
||
for (size_t j = 1; j < bytes && i + j < str.size(); ++j)
|
||
cp = (cp << 6) | (b(j) & 0x3F);
|
||
i += bytes;
|
||
return cp;
|
||
}
|
||
|
||
TextRenderer::~TextRenderer()
|
||
{
|
||
glyphs.clear();
|
||
atlasTexture.reset();
|
||
textMesh.positionVBO.reset();
|
||
}
|
||
|
||
bool TextRenderer::init(Renderer& renderer, const std::string& ttfPath, int pixelSize, const std::string& zipfilename)
|
||
{
|
||
|
||
r = &renderer;
|
||
|
||
static bool shaderAlreadyAdded = false;
|
||
|
||
if (shaderAlreadyAdded == false)
|
||
{
|
||
shaderAlreadyAdded = true;
|
||
|
||
#ifdef EMSCRIPTEN
|
||
r->shaderManager.AddShaderFromFiles(shaderName,
|
||
"resources/shaders/text2d.vertex",
|
||
"resources/shaders/text2d_web.fragment",
|
||
zipfilename
|
||
);
|
||
#else
|
||
r->shaderManager.AddShaderFromFiles(shaderName,
|
||
"resources/shaders/text2d.vertex",
|
||
"resources/shaders/text2d_desktop.fragment",
|
||
zipfilename
|
||
);
|
||
#endif
|
||
|
||
}
|
||
ZL::CheckGlError(__FILE__, __LINE__);
|
||
if (!loadGlyphs(ttfPath, pixelSize, zipfilename)) return false;
|
||
ZL::CheckGlError(__FILE__, __LINE__);
|
||
|
||
textMesh.data.PositionData.resize(6, Eigen::Vector3f(0, 0, 0));
|
||
textMesh.RefreshVBO();
|
||
ZL::CheckGlError(__FILE__, __LINE__);
|
||
return true;
|
||
}
|
||
|
||
void TextRenderer::ClearCache()
|
||
{
|
||
cache.clear();
|
||
}
|
||
|
||
bool TextRenderer::loadGlyphs(const std::string& ttfPath, int pixelSize, const std::string& zipfilename)
|
||
{
|
||
std::string cacheKey = ttfPath + "|" + zipfilename + "|" + std::to_string(pixelSize);
|
||
auto cacheIt = s_glyphAtlasCache.find(cacheKey);
|
||
if (cacheIt != s_glyphAtlasCache.end()) {
|
||
const GlyphAtlasData& cached = cacheIt->second;
|
||
glyphs = cached.glyphs;
|
||
atlasTexture = cached.atlasTexture;
|
||
atlasWidth = cached.atlasWidth;
|
||
atlasHeight = cached.atlasHeight;
|
||
lineHeight = cached.lineHeight;
|
||
return true;
|
||
}
|
||
|
||
// 1. Загружаем сырые данные из ZIP
|
||
std::vector<char> fontData;
|
||
try {
|
||
fontData = !zipfilename.empty()
|
||
? readFileFromZIP(ttfPath, zipfilename)
|
||
: readFile(ttfPath); // Предполагаем наличие функции readFile
|
||
}
|
||
catch (const std::exception& e) {
|
||
std::cerr << "TextRenderer: failed to read font data: " << e.what() << "\n";
|
||
return false;
|
||
}
|
||
|
||
if (fontData.empty()) return false;
|
||
|
||
FT_Library ft;
|
||
if (FT_Init_FreeType(&ft)) {
|
||
std::cerr << "FreeType: FT_Init_FreeType failed\n";
|
||
return false;
|
||
}
|
||
|
||
// 2. Инициализируем Face из памяти
|
||
FT_Face face;
|
||
// Важно: передаем указатель на буфер и его размер
|
||
if (FT_New_Memory_Face(ft,
|
||
reinterpret_cast<const FT_Byte*>(fontData.data()),
|
||
static_cast<FT_Long>(fontData.size()),
|
||
0,
|
||
&face))
|
||
{
|
||
std::cerr << "FreeType: failed to load font from memory: " << ttfPath << "\n";
|
||
FT_Done_FreeType(ft);
|
||
return false;
|
||
}
|
||
|
||
// Ensure we use the Unicode charmap so Cyrillic codepoints resolve correctly.
|
||
FT_Select_Charmap(face, FT_ENCODING_UNICODE);
|
||
FT_Set_Pixel_Sizes(face, 0, pixelSize);
|
||
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
|
||
|
||
// glyphs.clear();
|
||
|
||
// Сначала собираем все глифы в память
|
||
struct GlyphBitmap {
|
||
int width = 0;
|
||
int height = 0;
|
||
std::vector<char> data; // R8 байты (ширина*height)
|
||
Eigen::Vector2f bearing;
|
||
unsigned int advance = 0;
|
||
};
|
||
|
||
std::vector<std::pair<uint32_t, GlyphBitmap>> glyphList;
|
||
glyphList.reserve(128 - 32);
|
||
|
||
int maxGlyphHeight = 0;
|
||
|
||
for (unsigned char c = 32; c < 128; ++c) {
|
||
if (FT_Load_Char(face, c, FT_LOAD_RENDER)) {
|
||
// пропускаем если не удалось загрузить, но сохраняем пустой запись с advance
|
||
GlyphBitmap gb;
|
||
gb.width = 0;
|
||
gb.height = 0;
|
||
gb.bearing = { 0.f, 0.f };
|
||
gb.advance = 0;
|
||
glyphList.emplace_back((uint32_t)c, std::move(gb));
|
||
continue;
|
||
}
|
||
|
||
GlyphBitmap gb;
|
||
gb.width = face->glyph->bitmap.width;
|
||
gb.height = face->glyph->bitmap.rows;
|
||
gb.bearing = Eigen::Vector2f((float)face->glyph->bitmap_left, (float)face->glyph->bitmap_top);
|
||
gb.advance = static_cast<unsigned int>(face->glyph->advance.x);
|
||
|
||
size_t dataSize = static_cast<size_t>(gb.width) * static_cast<size_t>(gb.height);
|
||
if (dataSize > 0) {
|
||
gb.data.assign(face->glyph->bitmap.buffer, face->glyph->bitmap.buffer + dataSize);
|
||
maxGlyphHeight = max(maxGlyphHeight, gb.height);
|
||
}
|
||
|
||
glyphList.emplace_back((uint32_t)c, std::move(gb));
|
||
}
|
||
|
||
// Load extra codepoints from resources/symbols.txt (UTF-8 encoded).
|
||
{
|
||
std::vector<char> symbolsRaw;
|
||
try {
|
||
symbolsRaw = !zipfilename.empty()
|
||
? readFileFromZIP("resources/symbols.txt", zipfilename)
|
||
: readFile("resources/symbols.txt");
|
||
}
|
||
catch (...) {} // optional file — silently skip if missing
|
||
|
||
if (!symbolsRaw.empty()) {
|
||
// Collect codepoints already loaded (ASCII 32-127) to skip duplicates.
|
||
std::unordered_set<uint32_t> seen;
|
||
for (auto& p : glyphList) seen.insert(p.first);
|
||
|
||
std::string symbolsStr(symbolsRaw.begin(), symbolsRaw.end());
|
||
size_t pos = 0;
|
||
while (pos < symbolsStr.size()) {
|
||
uint32_t cp = nextUtf8Codepoint(symbolsStr, pos);
|
||
if (cp < 32 || !seen.insert(cp).second) continue; // skip controls and duplicates
|
||
|
||
GlyphBitmap gb;
|
||
if (FT_Load_Char(face, cp, FT_LOAD_RENDER)) {
|
||
gb.width = 0; gb.height = 0;
|
||
gb.bearing = { 0.f, 0.f };
|
||
gb.advance = 0;
|
||
} else {
|
||
gb.width = face->glyph->bitmap.width;
|
||
gb.height = face->glyph->bitmap.rows;
|
||
gb.bearing = Eigen::Vector2f((float)face->glyph->bitmap_left,
|
||
(float)face->glyph->bitmap_top);
|
||
gb.advance = static_cast<unsigned int>(face->glyph->advance.x);
|
||
size_t dataSize = static_cast<size_t>(gb.width) * static_cast<size_t>(gb.height);
|
||
if (dataSize > 0) {
|
||
gb.data.assign(face->glyph->bitmap.buffer,
|
||
face->glyph->bitmap.buffer + dataSize);
|
||
maxGlyphHeight = max(maxGlyphHeight, gb.height);
|
||
}
|
||
}
|
||
glyphList.emplace_back(cp, std::move(gb));
|
||
}
|
||
}
|
||
}
|
||
|
||
lineHeight = std::max<float>(static_cast<float>(pixelSize) * 1.3f, static_cast<float>(maxGlyphHeight) * 1.25f);
|
||
|
||
// Пакуем глифы в один атлас (упрощённый алгоритм строковой укладки)
|
||
const int padding = 1;
|
||
const int maxAtlasWidth = 1024; // безопасное значение для большинства устройств
|
||
int curX = padding;
|
||
int curY = padding;
|
||
int rowHeight = 0;
|
||
int neededWidth = 0;
|
||
int neededHeight = 0;
|
||
|
||
// Предварительно вычислим требуемый размер, укладывая в maxAtlasWidth
|
||
for (auto& p : glyphList) {
|
||
const GlyphBitmap& gb = p.second;
|
||
int w = gb.width;
|
||
int h = gb.height;
|
||
if (curX + w + padding > maxAtlasWidth) {
|
||
// новая строка
|
||
neededWidth = max(neededWidth, curX);
|
||
curX = padding;
|
||
curY += rowHeight + padding;
|
||
rowHeight = 0;
|
||
}
|
||
curX += w + padding;
|
||
rowHeight = max(rowHeight, h);
|
||
}
|
||
neededWidth = max(neededWidth, curX);
|
||
neededHeight = curY + rowHeight + padding;
|
||
|
||
// Подгоняем к степеням двух (необязательно, но часто удобно)
|
||
auto nextPow2 = [](int v) {
|
||
int p = 1;
|
||
while (p < v) p <<= 1;
|
||
return p;
|
||
};
|
||
|
||
atlasWidth = static_cast<size_t>(nextPow2(max(16, neededWidth)));
|
||
atlasHeight = static_cast<size_t>(nextPow2(max(16, neededHeight)));
|
||
|
||
// Ограничение - если получилось слишком большое, попробуем без power-of-two
|
||
if (atlasWidth > 4096) atlasWidth = static_cast<size_t>(neededWidth);
|
||
if (atlasHeight > 4096) atlasHeight = static_cast<size_t>(neededHeight);
|
||
|
||
// Создаём буфер атласа, инициализируем нулями (прозрачность)
|
||
std::vector<char> atlasData(atlasWidth * atlasHeight, 0);
|
||
|
||
// Второй проход - размещаем глифы и заполняем atlasData
|
||
curX = padding;
|
||
curY = padding;
|
||
rowHeight = 0;
|
||
|
||
for (auto &p : glyphList) {
|
||
uint32_t ch = p.first;
|
||
GlyphBitmap &gb = p.second;
|
||
|
||
if (gb.width == 0 || gb.height == 0) {
|
||
// пустой глиф — записываем UV с нулевым размером и метрики
|
||
GlyphInfo gi;
|
||
gi.size = Eigen::Vector2f(0.f, 0.f);
|
||
gi.bearing = gb.bearing;
|
||
gi.advance = gb.advance;
|
||
gi.uv = Eigen::Vector2f(0.f, 0.f);
|
||
gi.uvSize = Eigen::Vector2f(0.f, 0.f);
|
||
glyphs.emplace(ch, gi);
|
||
continue;
|
||
}
|
||
|
||
if (curX + gb.width + padding > static_cast<int>(atlasWidth)) {
|
||
// новая строка
|
||
curX = padding;
|
||
curY += rowHeight + padding;
|
||
rowHeight = 0;
|
||
}
|
||
|
||
// Копируем строки глифа в atlasData
|
||
for (int row = 0; row < gb.height; ++row) {
|
||
// FreeType буфер, как мы ранее использовали, хранит строки подряд.
|
||
// Копируем gb.width байт из gb.data на позицию (curX, curY + row)
|
||
int destY = curY + row;
|
||
int destX = curX;
|
||
char* destPtr = atlasData.data() + destY * atlasWidth + destX;
|
||
const char* srcPtr = gb.data.data() + row * gb.width;
|
||
std::memcpy(destPtr, srcPtr, static_cast<size_t>(gb.width));
|
||
}
|
||
|
||
// Сохраняем информацию о глифе (в пикселях и UV)
|
||
GlyphInfo gi;
|
||
gi.size = Eigen::Vector2f((float)gb.width, (float)gb.height);
|
||
gi.bearing = gb.bearing;
|
||
gi.advance = gb.advance;
|
||
|
||
// UV: нормализуем относительно размера атласа. Здесь uv указывает на верх-лево.
|
||
gi.uv = Eigen::Vector2f((float)curX / (float)atlasWidth, (float)curY / (float)atlasHeight);
|
||
gi.uvSize = Eigen::Vector2f((float)gb.width / (float)atlasWidth, (float)gb.height / (float)atlasHeight);
|
||
|
||
glyphs.emplace(ch, gi);
|
||
|
||
curX += gb.width + padding;
|
||
rowHeight = max(rowHeight, gb.height);
|
||
}
|
||
|
||
// Создаём Texture из atlasData (R8)
|
||
TextureDataStruct atlasTex;
|
||
atlasTex.width = atlasWidth;
|
||
atlasTex.height = atlasHeight;
|
||
atlasTex.format = TextureDataStruct::R8;
|
||
atlasTex.mipmap = TextureDataStruct::NONE;
|
||
atlasTex.data = std::move(atlasData);
|
||
|
||
atlasTexture = std::make_shared<Texture>(atlasTex);
|
||
|
||
// Очистка
|
||
FT_Done_Face(face);
|
||
FT_Done_FreeType(ft);
|
||
|
||
// После FT_Done_Face данные из fontData больше не нужны,
|
||
// вектор сам очистится при выходе из функции.
|
||
|
||
glBindTexture(GL_TEXTURE_2D, 0);
|
||
|
||
GlyphAtlasData& entry = s_glyphAtlasCache[cacheKey];
|
||
entry.glyphs = glyphs;
|
||
entry.atlasTexture = atlasTexture;
|
||
entry.atlasWidth = atlasWidth;
|
||
entry.atlasHeight = atlasHeight;
|
||
entry.lineHeight = lineHeight;
|
||
|
||
return true;
|
||
}
|
||
|
||
float TextRenderer::measureTextWidth(const std::string& text, float scale) const
|
||
{
|
||
if (text.empty()) {
|
||
return 0.0f;
|
||
}
|
||
|
||
float penX = 0.0f;
|
||
float maxLineWidth = 0.0f;
|
||
|
||
size_t textPos = 0;
|
||
while (textPos < text.size()) {
|
||
uint32_t cp = nextUtf8Codepoint(text, textPos);
|
||
|
||
if (cp == '\n') {
|
||
maxLineWidth = max(maxLineWidth, penX);
|
||
penX = 0.0f;
|
||
continue;
|
||
}
|
||
|
||
auto it = glyphs.find(cp);
|
||
if (it == glyphs.end()) {
|
||
continue;
|
||
}
|
||
|
||
const GlyphInfo& g = it->second;
|
||
penX += static_cast<float>(g.advance >> 6) * scale;
|
||
}
|
||
|
||
maxLineWidth = max(maxLineWidth, penX);
|
||
return maxLineWidth;
|
||
}
|
||
|
||
void TextRenderer::drawText(const std::string& text, float x, float y, float scale, bool centered, std::array<float, 4> color, float projW, float projH)
|
||
{
|
||
if (!r || text.empty() || !atlasTexture) return;
|
||
if (projW <= 0.0f) projW = Environment::projectionWidth;
|
||
if (projH <= 0.0f) projH = Environment::projectionHeight;
|
||
|
||
// формируем ключ кеша
|
||
std::string key = text + "|" + std::to_string(scale) + "|" + (centered ? "1" : "0");
|
||
auto itCache = cache.find(key);
|
||
|
||
if (itCache == cache.end()) {
|
||
VertexDataStruct textData;
|
||
float penX = 0.0f;
|
||
float penY = 0.0f;
|
||
|
||
float totalW = 0.0f;
|
||
float maxH = 0.0f;
|
||
|
||
float maxLineWidth = 0.0f;
|
||
size_t textPos = 0;
|
||
while (textPos < text.size()) {
|
||
uint32_t cp = nextUtf8Codepoint(text, textPos);
|
||
if (cp == '\n') {
|
||
maxLineWidth = max(maxLineWidth, penX);
|
||
penX = 0.0f;
|
||
penY -= lineHeight * scale;
|
||
continue;
|
||
}
|
||
|
||
auto git = glyphs.find(cp);
|
||
if (git == glyphs.end()) continue;
|
||
const GlyphInfo& g = git->second;
|
||
|
||
float xpos = penX + g.bearing.x() * scale;
|
||
float ypos = penY - (g.size.y() - g.bearing.y()) * scale;
|
||
float w = g.size.x() * scale;
|
||
float h = g.size.y() * scale;
|
||
|
||
// Добавляем 2 треугольника (6 вершин) для текущего символа
|
||
textData.PositionData.push_back({ xpos, ypos + h, 0.0f });
|
||
textData.PositionData.push_back({ xpos, ypos, 0.0f });
|
||
textData.PositionData.push_back({ xpos + w, ypos, 0.0f });
|
||
textData.PositionData.push_back({ xpos, ypos + h, 0.0f });
|
||
textData.PositionData.push_back({ xpos + w, ypos, 0.0f });
|
||
textData.PositionData.push_back({ xpos + w, ypos + h, 0.0f });
|
||
|
||
// TexCoords — на основе UV позиции и размера в атласе (uv указывает на верх-лево)
|
||
float u0 = g.uv.x();
|
||
float v0 = g.uv.y();
|
||
float u1 = u0 + g.uvSize.x();
|
||
float v1 = v0 + g.uvSize.y();
|
||
|
||
textData.TexCoordData.push_back({ u0, v0 });
|
||
textData.TexCoordData.push_back({ u0, v1 });
|
||
textData.TexCoordData.push_back({ u1, v1 });
|
||
textData.TexCoordData.push_back({ u0, v0 });
|
||
textData.TexCoordData.push_back({ u1, v1 });
|
||
textData.TexCoordData.push_back({ u1, v0 });
|
||
|
||
penX += (g.advance >> 6) * scale;
|
||
totalW = penX;
|
||
maxH = max(maxH, h);
|
||
}
|
||
totalW = max(maxLineWidth, penX);
|
||
|
||
// Сохраняем в кеш
|
||
CachedText ct;
|
||
ct.width = totalW;
|
||
ct.height = maxH;
|
||
ct.mesh.AssignFrom(textData);
|
||
|
||
auto res = cache.emplace(key, std::move(ct));
|
||
itCache = res.first;
|
||
}
|
||
|
||
// Используем кешированный меш
|
||
CachedText& cached = itCache->second;
|
||
|
||
// Вычисляем смещение для проекции (оставляем Y как есть)
|
||
float tx = x;
|
||
if (centered) {
|
||
tx = x - cached.width * 0.5f;
|
||
}
|
||
float ty = y;
|
||
|
||
// 3. Обновляем VBO через наш стандартный механизм
|
||
// Примечание: для текста лучше использовать GL_DYNAMIC_DRAW,
|
||
// но RefreshVBO сейчас жестко зашит на GL_STATIC_DRAW.
|
||
// Для UI это обычно не критично, если строк не тысячи.
|
||
|
||
// 4. Рендеринг
|
||
r->shaderManager.PushShader(shaderName);
|
||
|
||
// Матрица проекции — используем переданные проекционные размеры.
|
||
// Это позволяет корректно рендерить текст как в основной сцене, так и внутри FBO.
|
||
Eigen::Matrix4f proj = Eigen::Matrix4f::Identity();
|
||
proj(0, 0) = 2.0f / projW;
|
||
proj(1, 1) = 2.0f / projH;
|
||
// Сдвигаем проекцию так, чтобы локальные координаты меша (pen-origin=0,0) оказались в (tx,ty)
|
||
proj(0, 3) = -1.0f + 2.0f * (tx) / projW;
|
||
proj(1, 3) = -1.0f + 2.0f * (ty) / projH;
|
||
|
||
//cached.mesh.RefreshVBO();
|
||
|
||
r->RenderUniformMatrix4fv("uProjection", false, proj.data());
|
||
r->RenderUniform1i("uText", 0);
|
||
r->RenderUniform4fv("uColor", color.data());
|
||
|
||
r->RenderUniform1f("uAlpha", 1.0f);
|
||
|
||
glActiveTexture(GL_TEXTURE0);
|
||
glBindTexture(GL_TEXTURE_2D, atlasTexture->getTexID());
|
||
|
||
// The R8 atlas is sampled as alpha; without blending the non-glyph area
|
||
// of every quad writes opaque black and hides whatever is behind it.
|
||
glEnable(GL_BLEND);
|
||
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
|
||
|
||
r->DrawVertexRenderStruct(cached.mesh);
|
||
|
||
//glDisable(GL_BLEND);
|
||
|
||
r->shaderManager.PopShader();
|
||
|
||
// Сброс бинда текстуры не обязателен, но можно для чистоты
|
||
glBindTexture(GL_TEXTURE_2D, 0);
|
||
}
|
||
} // namespace ZL
|