Lite2D 是我 2016 年 10 月开始写的一个 2D 游戏框架。起因很直接:我当时刚自学完 cocos2d-x,用它做了几个小游戏之后意识到,「调 API 能做出东西」和「知道它怎么做出来」是两回事,就想再往深走一步——自己从头写一个 2D 引擎,把渲染、音频、网络、纹理解码这一整串技术从原理层亲手实现一遍。所以 Lite2D 的定位一开始就很明确:一次以练手和深入学习为主的项目,把 C++11、OpenGL、音频、网络、纹理解码这些技术串起来实际动一遍。
项目整体 91 个源文件 · 约 9500 行 C++ 代码,用 GLFW 创建窗口、OpenGL 1.x 立即模式渲染、FMOD 播放音频、libcurl 发起网络请求、FreeType 渲染文字、libpng/libjpeg 解码纹理。
这篇文章按模块把当时的实现思路讲清楚:主循环、双线程分工、RenderCmd 队列、Ref + AutoReleasePool、场景图、纹理二级缓存、动作系统、鼠标事件状态机、文字的单字纹理、Http 回调投递到主线程,最后用拿 Lite2D 写的 FlappyBird 把所有模块串一遍。
目录 — Table of Contents
整体地图:我把 Lite2D 分成了哪几块
01 · 工程构成与模块地图
工程目录不大,但我在划分职责时刻意让每个目录只负责一层事。按职责分成 8 个目录:
| 目录 | 文件 | 职责 |
|---|---|---|
base/ | Ref, AutoReleasePool, Node, Director, Application, Scheduler, EventDispatcher, GLView, Vec2, Math | 核心对象体系、主循环、事件派发、数学工具 |
2d/ | Sprite, Scene, Action, MoveTo, ScaleTo, RotationTo, Sequence, Animation, ActionMgr | 可见对象与动作 |
render/ | Image, Texture2D, TextureCache, RenderCmd, RenderCmdQueue | 纹理解码与渲染命令 |
ui/ | Text, Button | UI 控件(都继承 Node) |
animation/ | AnimationCache | 帧动画缓存 |
audio/ | AudioEngine | FMOD Ex 封装 |
network/ | HttpClient | libcurl 异步封装 |
data/ | PlayerSave | TinyXML 键值存档 |
我把所有模块的入口收成一个 Lite2D.h 头文件,用户侧只要 #include "Lite2D/Lite2D.h"。链接时绑定一批三方库:GLFW、OpenGL、libpng、libjpeg、zlib、FreeType、libcurl、FMOD Ex、TinyXML。
02 · 分层架构示意图
我对分层的硬约束是"只允许自上而下调用":游戏层只碰 2d/ 和 ui/;2d/ui 依赖 base/;base/ 的 Director 才去指挥 render/ 和三方库。反过来的依赖我一个都不留 —— 这是我把代码压到这个规模的关键。
入口与主循环:一帧到底发生了什么
03 · Application 启动次序
引擎一启动,我让所有单例按固定顺序唤醒。在 base/Application.cpp 中,构造函数就是一串 getInstance:
Application_Win32::Application_Win32(const char* title, int w, int h)
{
Director::getInstance()->init(title, w, h);
AutoReleasePool::getInstance();
AudioEngine::getInstance();
TextureCache::getInstance();
AnimationCache::getInstance();
PlayerSave::getInstance();
HttpClient::getInstance();
}
这个顺序是我仔细排过的:Director 先起,因为它拥有渲染线程的 RenderCmdQueue;AutoReleasePool 必须早于任何 Ref 的 autorelease() 出现;AudioEngine 初始化 FMOD 时要加载 dll;TextureCache / AnimationCache 是纯容器可以晚启;PlayerSave 启动会读本地 XML;HttpClient 启动就开一个工作线程,此时还没请求,它在条件变量上睡觉。
接下来的 app.run() 就是真正进入主循环:
void Application_Win32::run()
{
LARGE_INTEGER freq, last, now;
QueryPerformanceFrequency(&freq);
QueryPerformanceCounter(&last);
double perFrame = 1.0 / 60.0;
while (!Director::getInstance()->isEnd())
{
QueryPerformanceCounter(&now);
double dt = (double)(now.QuadPart - last.QuadPart) / freq.QuadPart;
if (dt >= perFrame) {
last = now;
Director::getInstance()->setDeltaTime(dt);
Director::getInstance()->mainLoop();
}
}
}
04 · Director 主循环六步
主循环的精华在 Director::mainLoop(),它在主线程每帧跑一次,严格六步:
- processOtherThreadFunc() — 先把其他线程(Http 工作线程 / 未来可能的 IO 线程)通过
addFuncToMainThread投递来的 lambda 执行一遍,这保证网络回调一定在主线程触发。 - _scheduler->update(_delta) — 驱动所有 schedule:每帧回调、定时回调、addScheduleUpdate 注册的 Node::update。动作系统 ActionMgr 也挂在这里。
- 派发 pollEvents — Director 向 _renderQueue 投递一个特殊 RenderCmd:在渲染线程调用
glfwPollEvents()并读取鼠标位置、按键状态,把结果交回 EventDispatcher。为什么要跨线程?因为 GLFW 要求窗口事件必须在拥有窗口句柄的线程上 poll,而窗口是在渲染线程建的。 - _renderQueue->clear() — 清空上一帧残留的命令数组(保留容量复用)。
- drawScene() — 递归遍历当前 Scene 的节点树,让每个可见 Node 把 RenderCmd 投到 RenderCmdQueue。这一步只产生命令,不碰任何 OpenGL API。
- AutoReleasePool::clear() + NextTick — 清空这帧的自动释放池,所有 ref==0 的对象销毁;切换下一帧。
05 · QueryPerformanceCounter 定帧
Windows 上 QueryPerformanceCounter 是亚微秒级的高精度计数器,比 GetTickCount(15.6ms) 和 timeGetTime(1ms) 都高一个数量级。所以我用它来做 60 FPS 的帧节流:
freq是每秒 tick 数,由QueryPerformanceFrequency填充,通常是 10MHz;- 每次 while 循环算一次
dt = (now - last)/freq; - 只有 dt ≥ 1/60 ≈ 16.67ms 才更新 last 并跑 mainLoop。
这是「忙等 + 阈值」实现,简单可靠,但 CPU 占用在空跑期会偏高。真实游戏里,drawScene 投递完命令就交回时间片,所以实际并不会打满一个核。
多线程设计:4 条线程的职责与同步
※ · 多线程设计全景
在展开"双线程分工"之前,先把 Lite2D 的整个线程图摆一遍。运行时引擎里会同时存在 4 条线程,每一条在源码里都能直接定位到 std::thread 的创建点,职责也彼此独立:
- 主线程(Main)
- 程序入口
WinMain/main所在的那条线程。跑Director::mainLoop(),驱动 Scheduler、ActionMgr、EventDispatcher 和场景图递归。所有业务代码(addChild、setVisible、按钮回调、HTTP 回调…)只在这条线程上发生。 - 渲染线程(Render)
RenderCmdQueue::create()里std::thread t(&ThreadFunc, ret); t.detach();拉起。它持有 OpenGL 上下文,120 FPS 忙等阈值触发,遍历_queue逐条cmd->exec()。所有 gl* 调用都只发生在这里。- HttpClient 工作线程
HttpClient::HttpClient()里auto t = std::thread([=]{ this->workFunc(); }); t.detach();。workFunc进入循环后立刻_condition.wait(_mutex),被send()唤醒才跑doRequest_curl。- PlayerSave 存盘线程(一次性)
PlayerSaveTinyXml::save()每次调用都std::thread t([&]{ doc->SaveFile(); }); t.detach();—— 写一次就退一次。setInt / setFloat / setString / setBool 每调用一次都会新建一条这样的线程。
把这 4 条线程按创建时机铺在一条进程时间轴上,生命周期的长短和交叠关系就很直观了:
跨线程通信通道
Lite2D 没有消息队列框架,也没有 future/promise,所有跨线程通信就靠几个显式的 mutex + vector/queue:
- 主 → 渲染 · 渲染命令
RenderCmdQueue::_queue(vector<RenderCmd*>)。主线程每帧drawScene里调用addRenderCmd填入,渲染线程render()消费。由_mutex保护。- 主 → 渲染 · 一次性 void 任务
_func1(vector<function<void()>>)。Director 每帧都往里投一个 lambda:在渲染线程调glfwPollEvents并读鼠标状态。- 主 → 渲染 · 带返回值的任务
_func(vector<function<Texture2D*()>>)。TextureCache 未命中时主线程塞一个创建纹理的 lambda,渲染线程执行完把Texture2D*写入tex_pool。- HTTP worker → 主
Director::addFuncToMainThread把 lambda 放入_queue_other_thread_func,主循环第一步processOtherThreadFunc()把它们一次性在主线程执行掉。libcurl 的write_data回调就靠这条通道把用户 HttpCallBack 投回主线程。- 主 → HTTP worker
HttpClient::send(request)入_queue_request然后_condition.notify_one(),worker 从wait里出来处理请求。- PlayerSave → 磁盘
- 直接
doc->SaveFile(),没有回流通道 —— 保存是否成功业务代码看不到。
同步原语清单
整个引擎用到的同步原语不多,每一个都能在源码里定位到:
static std::mutex _mutex_mainThread(base/Director.cpp文件静态)—— 保护_queue_other_thread_func。RenderCmdQueue::_mutex(成员)—— 保护_queue、_func、_func1、tex_pool的写入路径。HttpClient::_mutex+std::condition_variable_any _condition—— 经典生产者–消费者。std::atomic<bool> RenderCmdQueue::isNextTick—— 替代 mutex 做了主线程「帧边界」的跨线程信号。static std::atomic<int> _Render_Fps(RenderCmdQueue.cpp)—— 渲染 FPS 的跨线程读写。
共享数据与线程本地
我的处理原则是:能线程本地就线程本地,能无锁就无锁。结果是绝大多数数据都只属于某一条线程:
- 只属于渲染线程
RenderCmdQueue::tex_pool(真正的 GL 纹理对象表)、GLFW window、OpenGL 上下文 —— 主线程永远不碰。- 只属于主线程
- Scheduler、ActionMgr、EventDispatcher、场景图 Node 树、TextureCache 的
unordered_map<hash, Texture2D*>(这里存的是指针,不直接访问 GL 对象)。 - 跨线程共享(必须加锁)
_queue_other_thread_func、RenderCmdQueue::_queue / _func / _func1、HttpClient::_queue_request。
从源码提炼的几条规则
- 一条线程 = 一个职责:渲染线程只管画、HTTP 线程只管 curl、PlayerSave 线程只管写盘。业务代码永远在主线程。
- OpenGL 属于渲染线程:
glfwMakeContextCurrent在ThreadFunc里调,所有 gl* 从此不离开这条线程。 - 回调永远投回主线程:
write_data里的 HttpCallBack 不直接在 curl 线程触发,而是通过addFuncToMainThread投递;业务代码对多线程完全无感。 - 队列一律加 mutex:
_queue、_func、_queue_request、_queue_other_thread_func全都走_mutex.lock()/unlock(),没有用锁自由容器。 - 可以原子就不用锁:
isNextTick每帧只被主线程写一次、渲染线程读几次,用std::atomic<bool>比一次 lock 便宜。
一条源码事实:三条常驻线程(渲染、HTTP、主循环外的其它任务)都是 detach(),全程不 join;HttpClient::~HttpClient 里只有一次 _condition.notify_all(),worker 也没回收。程序终止完全靠进程退出。这一选择让启动/关闭代码非常短,代价是 Lite2D 不支持优雅关闭,对当时这个练手项目来说是可以接受的取舍。
06 · 双线程分工与数据流
我把游戏循环和 OpenGL 调用分到两个线程跑。主线程 60 FPS,渲染线程 120 FPS。主线程只负责「组织命令」,渲染线程只负责「执行命令」,两者之间就靠 _renderQueue->_queue 这一个 vector 传递 RenderCmd 指针。这样做的好处是:不管用户代码写得多慢,渲染节奏是独立的。
07 · GLView 必须在渲染线程创建
在 base/GLView.cpp 中,init() 做了这些事:
glfwWindowHint(GLFW_RED_BITS, 5);
glfwWindowHint(GLFW_GREEN_BITS, 6);
glfwWindowHint(GLFW_BLUE_BITS, 5);
_window = glfwCreateWindow(_w, _h, _title, nullptr, nullptr);
glfwMakeContextCurrent(_window);
这里是个必须一次做对的点:glfwMakeContextCurrent 会把 OpenGL 上下文绑定到调用它的那个线程。一旦绑定,该线程之后所有 gl* 才合法。所以我把 GLView::init 放进 RenderCmdQueue::ThreadFunc 里执行 —— 上下文天然属于渲染线程,主线程从此永远不能碰 gl*。踩过一次这个坑后,我再也没把窗口创建放在主线程。
08 · 主线程到渲染线程的桥
从 render/RenderCmdQueue.h 看,队列里不只有可绘制的 RenderCmd,还有两种函数指针通道:
class RenderCmdQueue {
public:
std::vector<RenderCmd*> _queue;
std::unordered_map<std::string, Texture2D*> tex_pool; // 渲染线程本地
std::function<Texture2D*()> _func; // 主线程阻塞请求渲染线程做一件事并取返回值
std::function<void()> _func1; // 主线程单向投递一次 void 任务
};
我用两条通道解决跨线程的问题:_func 是同步的 —— TextureCache::addImage 要创建新纹理时,主线程赋值 _func 然后在条件变量上等;渲染线程看到 _func 非空就执行并把结果写回,再通知主线程醒来。_func1 是异步的 —— Director 用它投递 pollEvents 之类的单向调用。这样我就把所有 OpenGL 资源的创建/销毁都统一成了「主线程提需求,渲染线程代办」。
Ref & AutoReleasePool:帧粒度的内存回收
09 · Ref 引用计数实现
我把整个对象体系的根放在 base/Ref.h。所有能进场景树的对象都从 Ref 继承,核心状态只有一个 int:
class Ref {
protected:
int _referenceCount = 1; // 出生即 1
public:
void retain() { ++_referenceCount; }
void release() {
if (--_referenceCount <= 0) delete this;
}
Ref* autorelease() {
AutoReleasePool::getInstance()->push(this);
return this;
}
};
这一套规则我是照 Cocos2d-x 的心智模型抄过来的,因为之前写过一段时间 Cocos 项目,团队成员切过来基本零成本:
new出来引用计数是 1,但这个 1 是「你持有」的那一次;Sprite::create()内部会对 new 出的对象做一次autorelease(),把这个 1 过渡到池子;- 使用方调
addChild,addChild 内部retain一次,计数变 2; - 帧末 pool clear:对池中每个对象
release一次(抵消 autorelease),剩下 1 属于父节点; - 父节点销毁或 removeChild 再 release —— 真正 delete。
10 · AutoReleasePool 帧级回收
base/AutoReleasePool.cpp 是一个极简单的 vector:
class AutoReleasePool {
std::vector<Ref*> _queue;
public:
AutoReleasePool() { _queue.reserve(100); }
void push(Ref* r) { _queue.push_back(r); }
void clear() {
for (auto* r : _queue) r->release();
_queue.clear();
}
};
我对这个设计最满意的一点是:用户代码里 addChild(Sprite::create(...)) 看起来像「传值」写法,但因为 retain 抬高了计数、pool 抵消了 create 里的 autorelease,对象能稳定活在树里,直到父节点 remove/析构,用户代码完全不用写 new/delete。一套两三百行的基础设施换来几万行业务代码的心智减负,这笔账非常划算。
11 · printTrack 内存泄漏排查
我给 Ref 加了一条全局链表用于排查泄漏:构造时把 this 加到 std::list<Ref*>,析构时摘掉。进程退出前调 Ref::printTrack() 就能看到当前没被释放的对象。这套工具非常便宜但非常值:写游戏时 99% 的内存问题都是「忘了 release 某个引用」,printTrack 直接把它们列出来。Demo/main.cpp 里我就这么用:
int main() {
{ Application_Win32 app; ...; app.run(); ... }
Ref::printTrack(); // 作用域结束后看是不是还有幽灵对象
return 0;
}
Node 场景图:世界变换是如何递归算出来的
12 · Node 字段与 addChild
Node 是 Sprite / Text / Button / Scene 的共同父类。我给它留的就是一组再经典不过的 2D 节点属性 —— 目的就是写代码的时候不用记新东西:
class Node : public Ref {
protected:
bool _visible = true;
Vec2 _position;
Vec2 _scale { 1, 1 };
float _rotation = 0;
Vec2 _anchor { 0.5, 0.5 };
float _opactiy = 1; // 原拼写,引擎内一致
int _localZ = 0;
Node* _parent = nullptr;
std::vector<Node*> _children;
};
addChild(Node* child) 的实现只有几行:
void Node::addChild(Node* c) {
c->retain();
c->_parent = this;
_children.push_back(c);
}
没有按 Z 排序 —— 排序是在遍历时做的(见 14 节)。
13 · 世界坐标递归合成
所有对象的 _position / _scale / _rotation 都是局部值。真正要渲染时要算出全局值,Node 提供三个 getGlobal 函数:
Vec2 Node::getGlobalPosition()
{
if (!_parent) return _position;
Vec2 p = _parent->getGlobalPosition();
Vec2 s = _parent->getGlobalScale();
float r = _parent->getGlobalRotation();
// 把自身局部坐标乘上父节点的 scale,再按父节点的 rotation 旋转,最后加父节点的世界坐标
Vec2 v = _position;
v.x *= s.x; v.y *= s.y;
float cr = cosf(r), sr = sinf(r);
Vec2 rot { v.x*cr - v.y*sr, v.x*sr + v.y*cr };
return { p.x + rot.x, p.y + rot.y };
}
getGlobalScale / getGlobalRotation 同理,但只做乘法或加法。这里我故意没用矩阵 —— 手写 SRT 直接递归到根,可读性换性能。理由很简单:2D 游戏的场景树一般就是 Scene → layer → sprite 三层,再深也不会超过 5 层。为这种深度专门搭矩阵栈的收益是负的。
14 · 局部 Z 排序与遮盖
Scene 在 drawScene 里对每一层 children 按 _localZ 稳定排序后再递归。这样即使插入顺序任意,只要指定了 Z 就能稳定呈现前后关系。我让 addChild 不触发排序,是为了批量 add 时不重复冒泡 —— 反正每帧 draw 前都会过一遍。
渲染命令队列:OpenGL 立即模式的现代封装
15 · RenderCmdQueue 字段剖析
一个完整的渲染命令队列要做到三件事:装命令、跨线程发单向任务、跨线程发同步任务。前面已经看到它的字段了:_queue / _func1 / _func。ThreadFunc 核心循环:
void RenderCmdQueue::ThreadFunc()
{
_view->init(); // 渲染线程内创建窗口,MakeContextCurrent
while (_alive) {
auto t0 = now();
// ① 如果主线程塞了 _func,执行并把返回值放到 _result,notify
// ② 如果主线程塞了 _func1,执行(pollEvents 等)
// ③ glClear
for (RenderCmd* c : _queue) c->exec();
glfwSwapBuffers(_view->window());
// 按 1/120 秒节流
}
}
16 · 两级纹理池 tex_pool
TextureCache(主线程 singleton)只管指针 —— 它按字符串(文件路径)做 xxHash32 然后查 unordered_map<unsigned int, Texture2D*>。但真正的 GL 纹理对象(glGenTextures 给的 id)是在渲染线程创建的,所以 RenderCmdQueue 里还有一个本地 tex_pool,只在渲染线程读写。
17 · RenderCmd_Quad 的 OpenGL 管线
所有精灵都走同一条命令类型 RenderCmd_Quad。它的 exec() 就是一段 OpenGL 1.x 立即模式代码,把 4 个顶点和 4 个纹理坐标喂给固定管线:
void RenderCmd_Quad::exec()
{
glLoadIdentity();
glBindTexture(GL_TEXTURE_2D, _texId);
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
glAlphaFunc(GL_GREATER, 0.0f);
glColor4f(1, 1, 1, _opacity);
glBegin(GL_QUADS);
glTexCoord2f(_tc[0], _tc[1]); glVertex2f(_v[0], _v[1]);
glTexCoord2f(_tc[2], _tc[3]); glVertex2f(_v[2], _v[3]);
glTexCoord2f(_tc[4], _tc[5]); glVertex2f(_v[4], _v[5]);
glTexCoord2f(_tc[6], _tc[7]); glVertex2f(_v[6], _v[7]);
glEnd();
}
我选立即模式(glBegin/glEnd)而非 VBO + shader,理由只有一个:代码量极小,读的人上手零门槛。代价很明显 —— 绘制 5 千以上精灵时会成为瓶颈。Demo/main.cpp 里那段「循环 10000 次 create Sprite」就是我自己对这个瓶颈做的极限压力测试,结论是够用。真要做中型 2D 游戏,下一步会把这层改成 VBO + 批处理,接口层对外不用动。
Sprite:从 Node 属性到 4 个顶点的变换链
18 · CPU 侧四步变换链
Sprite::draw 的职责是:拿到自身的世界变换 + 纹理的 4 个角坐标,算出屏幕上 4 个顶点,再填到 cmd 里。整个流程全 CPU,不依赖 OpenGL 的 modelview matrix:
19 · NDC 归一化与翻转
Lite2D 的坐标系约定:窗口左下为原点 (0,0),宽 w、高 h,OpenGL 默认 NDC 是 [-1, 1]。所以每个顶点最后都要:x_ndc = x / w * 2 - 1,y_ndc = y / h * 2 - 1。
翻转(setFlipX / setFlipY)不是改几何,而是改纹理坐标的对应关系。Sprite 内部有四个索引 _i1,_i2,_i3,_i4,指向 _tc 的 4 组 (u,v)。X 翻转时交换 _i1 ↔ _i3,Y 翻转时交换 _i2 ↔ _i4。这样 4 个顶点的 (u,v) 顺序变了,视觉上就翻过来了。
20 · cmd 缓存避免每帧 new
一个经常被忽视的细节:Sprite 自带一个 RenderCmd_Quad* _cmd,是构造时就 new 好的,每帧 draw 时只更新字段,然后 push 到队列。
void Sprite::draw()
{
// ... 算 4 个顶点的 NDC ...
memcpy(_cmd->_v, v, sizeof(v));
memcpy(_cmd->_tc, _tc, sizeof(_tc));
_cmd->_texId = _tex->getId();
_cmd->_opacity = _opactiy * _parentOpacity;
Director::getInstance()->getRenderQueue()->push(_cmd);
}
为什么要这样做:10000 个 Sprite 每帧 new 10000 个 cmd 会把 malloc 吃光。我用 Sprite 持有 cmd 这种朴素的对象池方案,代价是每个 Sprite 多占约 128 字节 —— 对 2D 游戏完全值得。这是典型的「用已经分配的对象的寿命,托管临时对象的寿命」。
纹理系统:BMP / PNG / JPG 三条解码路径
21 · Image:三路解码入口
图像这一层我写得最「杂」。render/Image.cpp 按扩展名分发三条独立路径,互相不依赖:
| 格式 | 库 | 输出通道 | 备注 |
|---|---|---|---|
| BMP | 手写(无第三方) | GL_BGR_EXT, 24bit | 要求宽高 2 的幂,否则 gluScaleImage 缩放 |
| PNG | libpng + zlib | GL_RGBA, 32bit | 使用 PNG_TRANSFORM_EXPAND 展开索引色 |
| JPG | libjpeg | GL_RGB, 24bit | glPixelStorei(GL_UNPACK_ALIGNMENT,1) |
22 · BMP 的 2 的幂缩放与字节序
BMP 是我在 Lite2D 里写得最「赤手空拳」的格式。loadWithBmp 打开文件后直接操作字节偏移 —— 因为 BMP 头太简单了,引第三方库反而不值:
fread(header, 1, 54, fp);
int w = *(int*)(header + 0x12);
int h = *(int*)(header + 0x16);
int line_bytes = ((w * 3 + 3) / 4) * 4; // 行字节按 4 对齐
OpenGL 的 texture target 在 1.x 上通常要求宽高是 2 的幂,所以我放了一张预定义的 2^n 表:
static int s_pow2[] = { 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096 };
// 找到 >= w 的最小 2 的幂,不等时 gluScaleImage 把原像素缩放过去
BMP 的像素顺序是 BGR(不是 RGB),我选择不转换,直接告诉 GL 用 GL_BGR_EXT 格式上传,采样时正好还原 RGB。CPU 少做一遍循环。对应的 UV 顺序写死在 s_coord2f_bmp = {0,0, 1,0, 1,1, 0,1}。
23 · Texture2D 上传差异
三种格式走不同的 glTexImage2D 分支:
// BMP
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, w, h, 0, GL_BGR_EXT, GL_UNSIGNED_BYTE, pixels);
// PNG
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels);
// JPG
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, w, h, 0, GL_RGB, GL_UNSIGNED_BYTE, pixels);
并且 PNG/JPG 的默认 UV 是 s_coord2f_png_or_jpg = {0,1, 1,1, 1,0, 0,0},正好在 Y 方向是翻的 —— 因为 PNG/JPG 的数据是从顶到底排的,而 OpenGL 期望从底到顶。把「翻不翻」这个判断前置到加载期、而不是每帧渲染时判断 —— 这是我写渲染代码的一条习惯:能在加载时做完的事,绝不拖到运行时。
24 · TextureCache & xxHash32 复用
TextureCache 这层我选了 xxHash32 压缩文件名为 32 位 uint 作为 unordered_map 的 key:
Texture2D* TextureCache::addImage(const char* file)
{
unsigned int h = XXH32(file, strlen(file), 0);
auto it = _textures.find(h);
if (it != _textures.end()) return it->second;
// 主线程等渲染线程在 _func 里解码、glGenTextures、glTexImage2D,并返回指针
Texture2D* tex = requestRender([&]() { return Texture2D::create(file); });
_textures[h] = tex;
return tex;
}
我选 xxHash32 而不是 std::hash<string> 的理由:每次 Sprite::create 都会命中这里,这是个热点。xxHash32 在这种高频调用里比 std::hash 快 3–5 倍,而且分布均匀。代价是理论上有 2^-32 的碰撞概率 —— 对我做的这个规模的游戏,完全可以忽略。真实项目里纹理数量 500 以内,连一次碰撞都遇不到。
动作系统:MoveTo / Sequence 是怎么驱动的
25 · Action 基类字段
2d/Action.h 里 Action 本身只是一个带时间累加器的壳:
class Action : public Ref {
protected:
float _speed = 1;
float _total_time;
bool _isdone = false;
bool _isLoop = false;
bool _is_init = false;
float current = 0;
Node* target = nullptr;
int _tag = 0;
virtual void update(float dt) = 0;
};
26 · MoveTo 的懒初始化
MoveTo 的构造不能知道 target —— 你 create 时还没 runAction。所以它把「计算步长」延迟到第一次 update:
void MoveTo::update(float dt)
{
if (!_is_init) {
_addition = _target_pos;
_addition.sub(target->getPosition());
_addition.div(60.0f * _total_time); // 每 1/60 秒移动多少
_is_init = true;
}
target->setPosition(target->getPosition() + _addition);
current += dt;
if (current >= _total_time) _isdone = true;
}
这里我埋了一个隐含前提:Scheduler 以 60 FPS 稳定频率调用 update,所以 dt ≈ 1/60,乘 60 回来得到「每秒位移 / total_time」。如果帧率剧烈波动,实际距离会和预期偏差。我当时做的权衡是:简单引擎不值得为动作精度牺牲代码量,2D 游戏里 60 FPS 很稳定,这个近似几乎不影响观感。真要做严格的 dt-based 动作,只要把这里的 60 换成 1/dt 就行 —— API 不用动。
27 · Sequence 的 va_list 组合
Sequence 要按顺序跑一串 action,接口用 C 可变参数:
Sequence* Sequence::create(Action* first, ...)
{
Sequence* s = new Sequence();
s->_actions.push_back(first);
va_list li;
va_start(li, first);
Action* a;
while ((a = va_arg(li, Action*))) s->_actions.push_back(a);
va_end(li);
return (Sequence*)s->autorelease();
}
必须传 nullptr 作为结尾,否则 va_arg 读到垃圾指针就 crash —— 这是 C varargs 的老问题。我当时没用 variadic template 有两个原因:一是 C++11 早期 VS 的模板调试符号不好使,二是我本来就打算给这套引擎做 Lua 绑定,C 接口更友好。今天如果重写会直接用 fold expression。
28 · ActionMgr 先清理后更新
void ActionMgr::update(float dt)
{
// ① 先把 _isdone 的 action 摘掉(否则遍历时 erase 危险)
for (auto it = _actions.begin(); it != _actions.end(); ) {
if ((*it)->isDone()) {
(*it)->release();
it = _actions.erase(it);
} else ++it;
}
// ② 再依次 update
for (auto* a : _actions) a->update(dt);
}
"先清理后更新"这个次序是我被坑出来的:最早写的时候是 update 里即时 erase done,结果 Sequence 推进一个子 action 变成 done 时,外层遍历还要拿下一个来 update,就访问到已释放的内存、随机 crash。把 erase 阶段拆出来放到前置就再也没出过问题。
事件系统:鼠标三态机与命中短路
29 · 鼠标状态机
我在 base/EventDispatcher.cpp 里写了一个小状态机,把 GLFW 的 press/release 转换成 Cocos2d-x 风格的三回调 —— 原因跟 Ref 一样,团队已经习惯 Cocos 的心智模型:
// 每帧 Director 从渲染线程取得 glfw 鼠标按键状态和坐标
if (curPressed && !_began) {
_began = true;
dispatchBegan(pos); // → Button._began()
}
else if (curPressed && _began && pos != _lastPos) {
dispatchMoved(pos);
}
else if (!curPressed && _began) {
_began = false;
dispatchEnded(pos);
}
30 · Button 命中测试短路
Button 持有两张图 _sp_normal / _sp_clicked,_began 时检查坐标是否在自己矩形内:
bool Button::_began(Vec2 p)
{
if (!Math::isInUI(this, p)) return false;
_hit = true;
_sp_normal->setVisible(false);
_sp_clicked->setVisible(true);
return true; // 命中才返回 true;EventDispatcher 再给 _moved/_ended
}
这里的关键是 began 的返回值:命中才回 true,派发器就把这个按钮标记为「按下中」,后续 moved/ended 只传给它;没命中直接跳过。我用这种"短路式"命中处理代替"每帧枚举所有按钮",省掉了 UI 密集时的遍历开销。
文字渲染:FreeType 给出灰度位图后该怎么变成纹理
31 · 单字符纹理
文字这层我写得最"粗":每个字符一张 OpenGL 纹理。不做图集、不做 distance field。渲染时把一串字符挨个当 Sprite 画出来就完事。做这个取舍是因为 —— 我主要用它写中等规模 UI,不是大段长文,图集带来的复杂度并不换到等量的收益。
void Text::buildChars()
{
wstring ws = toUnicode(_str);
for (wchar_t c : ws) {
FT_Load_Char(_face, c, FT_LOAD_RENDER);
int w = _face->glyph->bitmap.width;
int h = _face->glyph->bitmap.rows;
unsigned char* gray = _face->glyph->bitmap.buffer;
// 转 RGBA:R,G,B 取 _color3b,A 取 gray;Y 翻转
...
glGenTextures(1, &tid);
glBindTexture(GL_TEXTURE_2D, tid);
glTexImage2D(..., GL_RGBA, ..., rgba);
}
}
32 · 灰度 → RGBA 翻转上传
FreeType 给出的 bitmap 是灰度(alpha-only),但 OpenGL 1.x 固定管线和 GL_ALPHA 纹理配合 glColor 上色的路径体验不好 —— 所以我在 CPU 侧把灰度展开成 RGBA:
for (int y = 0; y < h; ++y) {
for (int x = 0; x < w; ++x) {
unsigned char g = gray[(h - 1 - y) * pitch + x]; // Y 翻转
rgba[(y*w + x)*4+0] = _color3b.r;
rgba[(y*w + x)*4+1] = _color3b.g;
rgba[(y*w + x)*4+2] = _color3b.b;
rgba[(y*w + x)*4+3] = g;
}
}
展开的代价是每个字符的纹理字节数是 FreeType 原始大小的 4 倍。对常见正文尺寸(16px ~ 50px)影响不大,但 Demo 里那个 160px 的中文字,单个纹理就能占几十 KB。我的判断是:文字不会出现在每帧都重新生成的热点路径上,所以这笔内存开销换渲染管线的简洁是值得的。
33 · MultiByteToWideChar 中文拆分
用户传入的字符串是 GB2312 编码(Windows 默认 ANSI)。要按「字符」拆必须先转 wchar:
wstring toUnicode(const string& s) {
int len = MultiByteToWideChar(CP_ACP, 0, s.c_str(), -1, nullptr, 0);
wstring ws(len, 0);
MultiByteToWideChar(CP_ACP, 0, s.c_str(), -1, &ws[0], len);
return ws;
}
我用 CP_ACP 而不是 CP_UTF8 是和 Windows 编辑器(VS 默认 GB2312)对齐 —— 当年我直接在 VS 里写字符串字面量,默认就是 GB2312。如果你把源文件存成 UTF-8,这里要改成 CP_UTF8,否则中文会变乱码。Demo 里 "中文AaBbCcDdEeFfGg1234567890中文" 能正确显示的前提就是 main.cpp 本身是 GB2312。这个点我一直想改成 UTF-8 优先,但改了就要动 Demo 里所有中文字面量,暂时没腾出手。
动画:Animation 为什么继承 Action
34 · Animation 继承 Action
我把 Animation 直接做成了 Action 的一种 —— 它就是按帧间隔切换 target 纹理的动作。这个归类好处很实在:Animation 能直接进 Sequence,能和 MoveTo 组合。如果单独拉一层 AnimationRunner 就没这个便利:
void Animation::update(float dt)
{
_acc += dt;
if (_acc >= _perFrameDelay) {
_acc = 0;
++_frameIdx;
if (_frameIdx >= (int)_frames.size()) {
if (_loops < 0) _frameIdx = 0;
else { _isdone = true; return; }
}
auto* sp = dynamic_cast<Sprite*>(target);
if (sp) sp->setTexture2D(_frames[_frameIdx]);
}
}
loops = -1 即 FlappyBird 里「小鸟永远扇翅膀」的实现。
35 · AnimationCache 共享帧数据
MainScene 里定义了一个 fly 动画:
Animation* ani = Animation::create();
ani->addFrameFile("res/b1.png");
ani->addFrameFile("res/b2.png");
ani->addFrameFile("res/b3.png");
ani->setPerFrameDelay(0.1);
ani->setLoops(-1);
AnimationCache::getInstance()->addAnimation("fly", ani);
AnimationCache 按名字存 Animation 模板,get 时返回一个 clone()。这样不同 Sprite 对同一个 "fly" 动画各自有独立状态(当前帧、累计时间)但共享帧纹理 —— 这是我特意分开状态和数据的结果,不然一只小鸟会影响另一只小鸟的帧进度。
Scheduler:每帧回调 + 定时回调两条路
36 · addScheduleUpdate 与定时器
调度器我拆成两种队列:每帧回调 + 定时回调:
class Scheduler {
std::vector<Ref*> _callback_queue_every; // 每帧
std::vector<ScheduleCallBack*> _callback_queue; // 定时
float _interval = 1.0f / 60.0f;
};
Node::addScheduleUpdate() 把 this 塞进 _callback_queue_every;每帧 Scheduler.update 时对每个 Ref* 尝试调用其虚函数 update()。游戏逻辑(FlappyBird 的 pipe 位移、碰撞检测)全靠这条路。
定时回调用法见 Demo/main.cpp:
Director::getInstance()->getScheduler()->addSchedule(
[=](void) {
for (auto* t : __queue) t->setColor(Color3B(rand()%255, ...));
},
0.01, // 每 10ms
999999, // 重复次数
1 // 分组 id
);
HttpClient:libcurl + 条件变量 + 主线程回调
37 · libcurl + 条件变量工作线程
HttpClient 是我单独起一个 worker 线程来做的,主线程调 get/post 入队后立刻返回;worker 在条件变量上睡觉,被唤醒就执行:
void HttpClient::workFunc()
{
while (_alive) {
std::unique_lock<std::mutex> lk(_mtx);
_cond.wait(lk, [&](){ return !_requests.empty() || !_alive; });
if (!_alive) break;
auto req = _requests.front(); _requests.pop(); lk.unlock();
doRequest(req);
}
}
doRequest 里就是标准 libcurl 样板:
CURL* h = curl_easy_init();
curl_easy_setopt(h, CURLOPT_URL, req->url.c_str());
curl_easy_setopt(h, CURLOPT_WRITEFUNCTION, &write_data);
curl_easy_setopt(h, CURLOPT_WRITEDATA, req);
curl_easy_perform(h);
curl_easy_cleanup(h);
38 · 回调投递回主线程
关键的一步是我把回调「偷偷」切回主线程:write_data 累完整个 response 后,不在 worker 里直接调用户回调,而是打包成 lambda 扔回主线程:
size_t write_data(char* ptr, size_t s, size_t n, void* ud)
{
Request* req = (Request*)ud;
req->body.append(ptr, s*n);
// 完成时(最后一块)把回调投递到主线程
if (完成) {
Director::getInstance()->addFuncToMainThread([=]() {
req->callback(req->body);
});
}
return s*n;
}
"回调必须在主线程"这个约束是我卡着不放的:因为回调里可能 new Sprite、addChild —— 而 AutoReleasePool 和场景树都不是线程安全的。把回调强制切到主线程后,用户代码就能像写同步代码一样自然,不用担心线程安全。这条规则和 iOS 的 dispatch_main_async 是同一个思路。
音频与存档:两段不超过百行的实用模块
39 · FMOD Ex 效果音缓存
AudioEngine 我只让它做两件事:启动 FMOD、缓存短音效。游戏项目里不差那点接口复杂度,但我希望它足够薄 —— 真要放长音乐 / 流式播放就自己扩。
bool AudioEngine::init()
{
FMOD_System_Create(&_system);
FMOD_System_Init(_system, 32, FMOD_INIT_NORMAL, nullptr);
return true;
}
void AudioEngine::playEffect(const char* path)
{
auto it = _cache.find(path);
FMOD_SOUND* snd;
if (it == _cache.end()) {
FMOD_System_CreateSound(_system, path, FMOD_DEFAULT, nullptr, &snd);
_cache[path] = snd;
} else snd = it->second;
FMOD_System_PlaySound(_system, snd, nullptr, 0, nullptr);
}
32 通道够绝大多数 2D 游戏;cache 避免重复解码 wav/mp3 —— FlappyBird 的 Wing.wav 每次点击都播放,但只解码一次。这就是为什么我用了 FMOD 而不是自己搞一套:解码 + 通道调度都是别人验证过的东西,我不想重造。
40 · PlayerSave TinyXML 键值
PlayerSave 是我给玩家存档做的极简抽象:map<string, Value> 持久化到 XML 文件。XML 是因为 TinyXML 足够轻,不想为了存档再引 json 库:
<root>
<item key="best">42</item>
<item key="volume">0.8</item>
</root>
对外只有 getInt / setInt / getString / setString 四个方法。setXxx 一调就立即写回磁盘 —— 这是我故意做的:游戏崩了也不丢分。FlappyBird 每次 game over 调一次 PlayerSave::getInstance()->setInt("best", best),就能保证下一次打开读到正确的最佳分数。存档次数多了性能会有点损 —— 但个人小游戏场景里完全不是瓶颈。
FlappyBird:所有模块串成一条线
41 · FlappyBird 如何串起所有模块
我用 Lite2D 自己写了一个 342 行的完整 FlappyBird 作为自验小游戏。它触达了引擎几乎所有功能 —— 对想了解这套 API 怎么用的朋友来说,这份源码是最直接的入口。
- Application
Application_Win32 app("flappy", 360, 640):定制窗口尺寸- Scene 切换
Director::runWithScene(new MainScene);点 new 按钮切到new GameScene- 背景 + Button
- Button 充当全屏点击层(same up/down image);click → 小鸟向上弹 50 像素
- Animation
- MainScene 初始化 "fly"(b1/b2/b3.png 三帧)入 cache,GameScene 复用
- update 驱动
addScheduleUpdate让 GameScene::update 每帧跑:小鸟 y−3,两根管子 x−3,到左边界归位并 y 随机化- 碰撞
- 裸算 abs(pipe.x − bird.x) < 40 + Y 范围判断,没有物理库
- 得分持久化
- PlayerSave 读 "best",破纪录时 setInt 写回
- 音效
- click 里 AudioEngine::playEffect("res/Wing.wav");MainScene 里播 logo.mp3
- UI 切换
- 一个 layer Node 包着 over/broad/new/exit 四张图,setVisible(true/false) 控制 over 状态
- HttpClient
- MainScene::init 里调
HttpClient::getInstance()只是显式启动,demo 没发真实请求
把这些点连起来看,就是我写 Lite2D 时脑子里的「典型使用场景」:
MainScene ──点 new──> GameScene
│ │
│ 加载 fly 动画 ├── update: 小鸟/管子物理
│ 播 logo 音效 ├── carry 命中 → setOver(true)
├── 按钮 new/exit ├── setOver(true) 写 best
└── 初始化 HttpClient └── layer 显示/隐藏结算
342 行能写出完整的 FlappyBird —— 这是我对 Lite2D 最想要的效果:API 不多,但每一个通路都打通。想到"我要搞个按钮"、"我要让小鸟扇翅膀"、"我要存最高分"时,代码量和脑子里的流程基本一对一。
尾声 · 整体数据流与阅读顺序建议
42 · 一帧数据流总览 & 读我代码的顺序
把前面 16 节的流动拼起来,就是 Lite2D 的完整一帧:
主线程每帧:
① processOtherThreadFunc() ← HttpClient 回调、其他线程排队的 lambda
② Scheduler::update(dt) ← update() 列表 + 定时回调列表
└ ActionMgr::update(dt) ← 先 erase done,再 update 全部
③ _renderQueue->_func1 = pollEvents ← 让渲染线程帮忙 poll 输入
└ EventDispatcher 状态机 ← 派发 began/moved/ended
④ _renderQueue->_queue.clear() ← 清掉上一帧的 cmd
⑤ Scene::drawScene() ← Node 递归遍历,组装 RenderCmd_Quad
⑥ AutoReleasePool::clear() ← 帧末释放所有 autorelease 对象
渲染线程循环:
① 取 _func / _func1 执行(创纹理 / pollEvents)
② glClear
③ for each cmd in _queue: cmd->exec() ← glBegin..glEnd
④ glfwSwapBuffers
如果你打算读一遍我的代码,下面是我推荐的顺序(按依赖自底向上,也大致是当年我一边画草稿一边写下来的次序):
- Ref.h/.cpp + AutoReleasePool — 先吃透对象生命周期,后面所有东西都建立在此
- Node.h/.cpp — 场景图 + 世界坐标,Sprite/Text/Button 只是它的子类
- Director + RenderCmdQueue — 主循环 + 双线程桥,这是我最花心思的一块
- RenderCmd(特别是 RenderCmd_Quad::exec) — 看到 gl 调用就见底了
- Sprite::draw — 连接"节点 → 命令"的桥
- TextureCache + Image + Texture2D — 纹理的三条路径,分开看就不乱了
- Action 家族 — 先看 Action.h,再看 MoveTo / Sequence
- EventDispatcher + Button — 状态机短路是我比较喜欢的写法
- Text + Animation — 本质都是 Sprite / Action 的扩展
- HttpClient / AudioEngine / PlayerSave — 三段辅助工具,谁都可以独立看
- FlappyBird/FlappyBird.cpp — 拿 342 行印证前面所有知识
附录
源码地址:https://gitee.com/dreamyouxi/Lite2D
— 全文 42 节 · 7 幅示意图 · 1 张游戏截图 · Lite2D 作者亲自撰写 —