C++游戏开发之旅 27
问题概述
实现添加文字的功能。
需要搭建一个文字渲染引擎
文字渲染引擎

核心思想:
- 用SDL_ttf 库渲染文字
- 创建通用的TextRenderer类
- 区分UI文本和世界文本
- 集成到引擎核心
文字渲染系统架构:
┌──────────────────┐
│ SDL_ttf │ ← 底层库
│ TTF_TextEngine │ 字体渲染
└────────┬─────────┘
│ 封装
↓
┌──────────────────┐
│ TextRenderer │ ← 引擎层
│ • drawUIText │ 统一接口
│ • drawText │
└────────┬─────────┘
│
┌────┴────┐
│ │
↓ ↓
┌────────┐┌────────┐
│UI Text ││World │ ← 应用层
│固定位置││Text │ 两种文本
└────────┘│跟随相机│
└────────┘
课程目标
- 了解SDL_ttf --- 学习 SDL 字体渲染库
- 创建 TextRenderer --- 封装字体加载和绘制逻辑
- 区分UI与世界文本 --- 两种坐标系统的文本渲染
- 集成到引擎 --- 作为核心服务添加到 Context
第一部分:SDL_ttf 基础
新版的SDL3有一个TTF_TextEngine,文字引擎,我们可以创建一个引擎实例,然后绘制文本对象,和旧版相比极大优化了性能。
以下是一个绘制方法,我们需要一个文字引擎,字体,文本,然后调用SDL_DrawRendererText方法。
TTF_TextEngine* text_engine = TTF_CreateRendererTextEngine(renderer_);
TTF_Font* font = TTF_OpenFont("assets/font/VonwaonBitmap-16px.ttf", 24);
TTF_Text* text = TTF_CreateText(text_engine, font, "Hello SDL3!", 0);
while(true)
{
SDL_SetRenderDrawColor(renderer_, 0, 0, 0, 255); // 设置颜色
SDL_RenderClear(renderer_); // 清屏
// 绘制文字
TTF_DrawRendererText(text, mouse_pos.x, mouse_pos.y);
SDL_RenderPresent(renderer_); // 显示
}
第二部分:TextRenderer设计与实现
1.TextRenderer的设计
src\engine\render\text_renderer.h
#pragma once
#include <SDL3/SDL_render.h> // SDL_Renderer
#include <string>
#include <glm/vec2.hpp>
struct TTF_TextEngine;
namespace engine::render {
class Camera;
}
namespace engine::resource{
class ResourceManager;
}
namespace engine::render {
class TextRenderer {
SDL_Renderer* sdl_renderer = nullptr;
TTF_TextEngine* text_engine_ = nullptr;
resource::ResourceManager* resource_manager_;
public:
TextRenderer(SDL_Renderer* renderer, resource::ResourceManager* resourceManager);
~TextRenderer();
// 绘制屏幕坐标UI
void drawUIText(const std::string& text, const std::string& font_id,
int font_size, const glm::vec2& position,
const SDL_FColor& color = {1.0f, 1.0f, 1.0f, 1.0f}
);
// 绘制世界坐标UI
void drawText(Camera& camera, const std::string& text, const std::string& font_id,
int font_size, const glm::vec2& position,
const SDL_FColor& color = {1.0f, 1.0f, 1.0f, 1.0f}
);
// 获取字体大小
glm::vec2 getTextSize(const std::string& text, const std::string& font_id, int font_size);
void close();
};
}
设计解析
TextRenderer 职责
- 字体管理 --- 依赖ResourceManager
- 文本渲染 --- drawUIText、drawText
- 尺寸计算 --- getTextSize
两个draw函数是关键,drawUIText()表示绘制屏幕内的UI坐标,比如菜单,商城,而drawText()则绘制世界坐标系下的文字,这是会受到相机的影响的。
工具函数
glm::vec2 getTextSize(const std::string& text, const std::string& font_id, int font_size);
glm::vec2 TextRenderer::getTextSize(const std::string &text, const std::string &font_id, int font_size)
{
TTF_Font* font = resource_manager_->getFont(font_id, font_size);
if(!font){
spdlog::error("{}字体加载错误", font_id);
return glm::vec2(0);
}
TTF_Text* c_text = TTF_CreateText(text_engine_, font, text.c_str(), 0); // 0表示不会换行
if(!c_text){
spdlog::error("文本创建失败{}",SDL_GetError());
return glm::vec2(0);
}
int w, h;
TTF_GetTextSize(c_text, &w, &h);
TTF_DestroyText(c_text);
return glm::vec2(static_cast<float>(w), static_cast<float>(h));
}
这个函数可以计算渲染一段文本需要多大的空间,这对于UI布局十分重要。
其可以返回我们一个文本大小,表示占据多少的空间,比如我们需要UI 居中对齐,我们可以获取一段文字的大小,那么就可以通过返回的宽高来决定绘制位置pos -= size / 2
src/engine/render/text_renderer.cpp
#include "text_renderer.h"
#include "../resource/resource_manager.h"
#include "camera.h"
#include <SDL3_ttf/SDL_ttf.h>
#include <spdlog/spdlog.h>
namespace engine::render{
TextRenderer::TextRenderer(SDL_Renderer *renderer, resource::ResourceManager *resourceManager)
: sdl_renderer(renderer),
resource_manager_(resourceManager)
{
if(sdl_renderer == nullptr || resource_manager_ == nullptr){
spdlog::error("sdl渲染器为空或是资源管理器为空,初始化字体渲染失败");
return;
}
// 初始化TTF
if(TTF_WasInit() == 0 && TTF_Init() == false){
spdlog::error("初始化 SDL_ttf 失败 {}", SDL_GetError());
return;
}
// 字体引擎
text_engine_ = TTF_CreateRendererTextEngine(sdl_renderer);
if(text_engine_ == nullptr){
spdlog::error("创建字体引擎失败");
return;
}
spdlog::info("字体渲染器初始化成功");
}
TextRenderer::~TextRenderer()
{
if(text_engine_){
close();
}
}
void TextRenderer::drawUIText(const std::string &text, const std::string &font_id, int font_size, const glm::vec2 &position, const SDL_FColor &color)
{
TTF_Font* font = resource_manager_->getFont(font_id, font_size);
if(!font){
spdlog::error("{}字体加载错误", font_id);
return;
}
TTF_Text* c_text = TTF_CreateText(text_engine_, font, text.c_str(), 0); // 0表示不会换行
if(!c_text){
spdlog::error("文本创建失败{}",SDL_GetError());
return;
}
// 先渲染一次黑色文字模拟阴影
TTF_SetTextColorFloat(c_text, 0, 0, 0, 1);
TTF_DrawRendererText(c_text, position.x + 2, position.y + 2);
// 正常绘制
TTF_SetTextColorFloat(c_text, color.r, color.g, color.b, color.a);
TTF_DrawRendererText(c_text, position.x, position.y);
TTF_DestroyText(c_text);
}
void TextRenderer::drawText(Camera &camera, const std::string &text, const std::string &font_id, int font_size, const glm::vec2 &position, const SDL_FColor &color)
{
auto pos = camera.worldToScreen(position); // 将世界坐标转换为屏幕坐标
drawUIText(text, font_id, font_size, pos, color);
}
glm::vec2 TextRenderer::getTextSize(const std::string &text, const std::string &font_id, int font_size)
{
TTF_Font* font = resource_manager_->getFont(font_id, font_size);
if(!font){
spdlog::error("{}字体加载错误", font_id);
return glm::vec2(0);
}
TTF_Text* c_text = TTF_CreateText(text_engine_, font, text.c_str(), 0); // 0表示不会换行
if(!c_text){
spdlog::error("文本创建失败{}",SDL_GetError());
return glm::vec2(0);
}
int w, h;
TTF_GetTextSize(c_text, &w, &h);
TTF_DestroyText(c_text);
return glm::vec2(static_cast<float>(w), static_cast<float>(h));
}
void TextRenderer::close()
{
if(text_engine_){
TTF_DestroyRendererTextEngine(text_engine_);
text_engine_ = nullptr;
}
TTF_Quit(); // 确保在ResourceManager 销毁之后调用
}
}
初始化流程:
- 检查 SDL_ttf 是否初始化 --- TTF_WasInit() == 0 ?
- 如果未初始化,则初始化 --- TTF_Init()
- 之后创建 TextEngine --- TTF_CreateRendererTextEngine
- 检查创建是否成功 --- if(text_engine_ == nullptr)
- 创建完成
在进行绘制文字的时候,我们会先渲染一次黑色的文字阴影,之后正常绘制,这使得我们的文字会有立体感
// 先渲染一次黑色文字模拟阴影
TTF_SetTextColorFloat(c_text, 0, 0, 0, 1);
TTF_DrawRendererText(c_text, position.x + 2, position.y + 2);
// 正常绘制
TTF_SetTextColorFloat(c_text, color.r, color.g, color.b, color.a);
TTF_DrawRendererText(c_text, position.x, position.y);
TTF_DestroyText(c_text);
当然我们创建的TTF_Text 字体对象是临时的,需要在绘制函数末尾销毁;drawText 绘制函数,只需要进行坐标转换就可以进行绘制了,这避免了代码重复。
drawUIText实现流程:
- 从资源管理器
resource_manager_获取字体资源getFont(font_id,font_size) - 使用字体引擎创建文本对象
TTF_CreateText(text_engine_, font, text.c_str()) - 绘制文本 阴影 黑色偏移+2 正常绘制
- 销毁临时对象
TTF_DestoryText()
第三部分:引擎集成
现在我们需要将TextRenderer像其他核心模块一样,添加到GameApp中,并通过Context提供给游戏的其他部分。
3.引擎集成
我们需要再GameApp中定义TextRenderer类并使用智能指针管理,Context类需要提供上下文引用获取该实例对象。
引擎核心服务总览
引擎核心服务:
┌──────────────────────┐
│ GameApp │
├──────────────────────┤
│ • ResourceManager │ ← 资源管理
│ • Renderer │ ← 图形渲染
│ • PhysicsEngine │ ← 物理引擎
│ • AudioPlayer │ ← 音频播放
│ • TextRenderer │ ← 文字渲染(新增)✨
└────────┬─────────────┘
│
↓
┌──────────────────────┐
│ Context │
│ (全局访问接口) │
└──────────────────────┘
这个流程应该是很熟悉了。这一种架构模式,让添加新的引擎功能变得清晰简洁。
第四部分:测试
现在我们已经实现了文字渲染模块的载入,在GameScene中编写一个简单的测试函数来验证一下。
4.测试文字渲染
src/game/scene/game_scene.h
void GameScene::testTextRender()
{
context_.getTextRenderer().drawUIText("Map Path", "assets/fonts/VonwaonBitmap-16px.ttf",32,
glm::vec2(200), {1.0f,1.0f,0.0f,1.0f});
context_.getTextRenderer().drawText(context_.getCamera(), "Hello World",
"assets/fonts/VonwaonBitmap-16px.ttf",16, glm::vec2(50,80));
}
该部分应该在render()函数中执行,最后的效果,我们可以实现UIText在屏幕中显示,其是固定的,不随相机移动,而世界字体是会随着相机移动的。
编译运行
可以实现文字渲染的功能。
章节总结
框架流程:
这章完成了文字渲染模块。我们通过SDL_ttf 底层库实现了字体渲染,需要使用到字体引擎TTF_TextEngine和字体资源TTF_Font,将其进行封装,文字渲染TextRenderer是依赖于资源管理器ResourceManager的,通过资源管理器加载字体资源TTF_Font,缓存资源,然后调用函数的时候进行一个判断,最终实现文字绘制功能。
设计模式应用:
封装模式
SDL_ttf 比较复杂,需要初始化(TTF_Init()),创建字体引擎(TTF_CreateRendererTextEngine(sdl_renderer)),通过字体引擎创建字体(TTF_CreateText()),字体需要设置颜色(TTF_SetTextColorFloat()),然后绘制字体(TTF_DrawRendererText()),之后字体还需要进行销毁(TTF_DestoryText())
而我们通过封装,只需要调用drawUIText(text,font,pos)就可以相对简单的绘制文本
职责划分
SDL_ttf --- 是底层渲染者,是C风格的API接口
ResourceManager --- 资源管理器,负责缓存字体资源,加载字体
TextRenderer --- 接口封装者,提供简单接口 C++封装
GameScene --- 业务使用者,渲染UI/文本,显示文字
遇到的问题
1.这里我们可以看到文字渲染模块中初始化构造实现TTF的初始化,而析构函数中自己销毁关闭TTF,这里有个细节问题了,我们需要保证,其调用析构是在资源管理器之后的,为什么?
因为官方文档对于TTF_Quit的备注是这样的
Please note that this does not automatically close any fonts that are still open at the time of deinitialization, and it is possibly not safe to close them afterwards, as parts of the library will no longer be initialized to deal with it. A well-written program should call TTF_CloseFont() on any open fonts before calling this function!
大概意思就是我们的资源管理器中缓存的字体资源应该先销毁之后,再调用TTF_Quit。我们需要保证这个析构的顺序以此来保证程序的安全性。

浙公网安备 33010602011771号