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 把所有模块串一遍。

阅读时长 约 35 分钟 91 个源文件 · 约 9500 行 C++ 代码 17 个章节 · 7 幅示意图 · 1 张游戏截图 项目起始 2016-10

目录 — Table of Contents

PART I · 整体
01工程构成与模块地图 02分层架构示意图
PART II · 入口与主循环
03Application 启动次序 04Director 主循环六步 05QueryPerformanceCounter 定帧
PART III · 多线程设计
多线程设计全景 06双线程分工与数据流 07GLView 必须在渲染线程创建 08主线程到渲染线程的桥
PART IV · 对象与内存
09Ref 引用计数实现 10AutoReleasePool 帧级回收 11printTrack 内存泄漏排查
PART V · 场景图
12Node 字段与 addChild 13世界坐标递归合成 14局部 Z 排序与遮盖
PART VI · 渲染命令队列
15RenderCmdQueue 字段剖析 16两级纹理池 tex_pool 17RenderCmd_Quad 的 OpenGL 管线
PART VII · Sprite
18CPU 侧四步变换链 19NDC 归一化 & 翻转 20cmd 缓存避免每帧 new
PART VIII · 纹理系统
21Image:BMP / PNG / JPG 三路解码 22BMP 的 2 的幂缩放与 BGR 字节序 23Texture2D 纹理上传差异 24TextureCache & xxHash32 复用
PART IX · 动作系统
25Action 基类字段 26MoveTo 的懒初始化 27Sequence 的 va_list 组合 28ActionMgr 先清理后更新
PART X · 事件系统
29鼠标状态机 BEGAN/MOVED/ENDED 30Button 命中测试短路
PART XI · 文字渲染
31FreeType 单字符纹理 32灰度 → RGBA 翻转上传 33MultiByteToWideChar 中文拆分
PART XII · 动画
34Animation 继承 Action 35AnimationCache 共享帧数据
PART XIII · 调度器
36addScheduleUpdate 与定时器
PART XIV · Http
37libcurl + 条件变量工作线程 38回调投递回主线程
PART XV · 音频与存档
39FMOD Ex 效果音缓存 40PlayerSave TinyXML 键值
PART XVI · 完整用例
41FlappyBird 如何串起所有模块
PART XVII · 尾声
42整体数据流 & 阅读路径建议
Part I

整体地图:我把 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, ButtonUI 控件(都继承 Node)
animation/AnimationCache帧动画缓存
audio/AudioEngineFMOD Ex 封装
network/HttpClientlibcurl 异步封装
data/PlayerSaveTinyXML 键值存档

我把所有模块的入口收成一个 Lite2D.h 头文件,用户侧只要 #include "Lite2D/Lite2D.h"。链接时绑定一批三方库:GLFW、OpenGL、libpng、libjpeg、zlib、FreeType、libcurl、FMOD Ex、TinyXML。

02 · 分层架构示意图

游戏层 — Game Code(TestScene / FlappyBird / 你的代码) 继承 Scene,重写 init() / update(),挂载 Sprite/Text/Button UI 层:Text / Button(ui/) · 可见对象层:Sprite / Scene / Animation(2d/) FreeType 单字 glyph,Button 持有 normal/clicked 两个 Sprite,Animation 继承 Action 动作层:Action / MoveTo / ScaleTo / RotationTo / Sequence / ActionMgr(2d/) 核心层 base/ — 场景图 / 生命周期 / 事件 / 调度 Node(场景树) · Ref(引用计数) · AutoReleasePool(帧回收) · Director(主循环) Application(启动单例) · Scheduler(定时器) · EventDispatcher(鼠标) · GLView(窗口) 渲染管线 render/ RenderCmd / RenderCmdQueue(命令) Image(解码)· Texture2D(GL 纹理) TextureCache(xxHash32 字符串键) 辅助服务 audio/AudioEngine — FMOD Ex 包裹 + 效果缓存 network/HttpClient — libcurl + 条件变量 data/PlayerSave — TinyXML 键值 平台层 · 第三方库 GLFW(窗口 & 输入)· OpenGL 1.x(立即模式)· libpng / libjpeg / zlib · FreeType · libcurl · FMOD Ex · TinyXML
图 1 · 从游戏代码到 GLFW/OpenGL 的 5 层架构

我对分层的硬约束是"只允许自上而下调用":游戏层只碰 2d/ui/2d/ui 依赖 base/base/ 的 Director 才去指挥 render/ 和三方库。反过来的依赖我一个都不留 —— 这是我把代码压到这个规模的关键。

Part II

入口与主循环:一帧到底发生了什么

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(),它在主线程每帧跑一次,严格六步:

  1. processOtherThreadFunc() — 先把其他线程(Http 工作线程 / 未来可能的 IO 线程)通过 addFuncToMainThread 投递来的 lambda 执行一遍,这保证网络回调一定在主线程触发。
  2. _scheduler->update(_delta) — 驱动所有 schedule:每帧回调、定时回调、addScheduleUpdate 注册的 Node::update。动作系统 ActionMgr 也挂在这里。
  3. 派发 pollEvents — Director 向 _renderQueue 投递一个特殊 RenderCmd:在渲染线程调用 glfwPollEvents() 并读取鼠标位置、按键状态,把结果交回 EventDispatcher。为什么要跨线程?因为 GLFW 要求窗口事件必须在拥有窗口句柄的线程上 poll,而窗口是在渲染线程建的。
  4. _renderQueue->clear() — 清空上一帧残留的命令数组(保留容量复用)。
  5. drawScene() — 递归遍历当前 Scene 的节点树,让每个可见 Node 把 RenderCmd 投到 RenderCmdQueue。这一步只产生命令,不碰任何 OpenGL API。
  6. AutoReleasePool::clear() + NextTick — 清空这帧的自动释放池,所有 ref==0 的对象销毁;切换下一帧。
我给自己定的铁律:主循环六步里,第 3、4、5 步会接触 _renderQueue,但它们都只向队列「塞命令」,从不直接触发 glDraw*。所有 OpenGL 调用只允许在渲染线程里发生 —— 这条约束一旦破掉,整个双线程模型就会开始出玄学 bug。

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 投递完命令就交回时间片,所以实际并不会打满一个核。

Part III

多线程设计: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 条线程按创建时机铺在一条进程时间轴上,生命周期的长短和交叠关系就很直观了:

进程时间轴 Director::init HttpClient::getInstance 主线程 Director::mainLoop · Scheduler · EventDispatcher · 场景图递归 渲染线程 ThreadFunc · 120 FPS · 持有 GL 上下文 · 遍历 _queue 执行 HTTP worker workFunc · condition_variable.wait → curl_easy_perform PlayerSave 存盘 每次 setXxx → save() 都 new + detach 一条短命线程,写完即退 main() / WinMain 入口 进程 exit 全部 detach · 无 join
图 · Lite2D 4 条线程的生命周期:3 条常驻(主 / 渲染 / HTTP),外加零散的一次性存盘线程

跨线程通信通道

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_func1tex_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_funcRenderCmdQueue::_queue / _func / _func1HttpClient::_queue_request

从源码提炼的几条规则

  1. 一条线程 = 一个职责:渲染线程只管画、HTTP 线程只管 curl、PlayerSave 线程只管写盘。业务代码永远在主线程。
  2. OpenGL 属于渲染线程glfwMakeContextCurrentThreadFunc 里调,所有 gl* 从此不离开这条线程。
  3. 回调永远投回主线程write_data 里的 HttpCallBack 不直接在 curl 线程触发,而是通过 addFuncToMainThread 投递;业务代码对多线程完全无感。
  4. 队列一律加 mutex_queue_func_queue_request_queue_other_thread_func 全都走 _mutex.lock() / unlock(),没有用锁自由容器。
  5. 可以原子就不用锁isNextTick 每帧只被主线程写一次、渲染线程读几次,用 std::atomic<bool> 比一次 lock 便宜。

一条源码事实:三条常驻线程(渲染、HTTP、主循环外的其它任务)都是 detach(),全程不 joinHttpClient::~HttpClient 里只有一次 _condition.notify_all(),worker 也没回收。程序终止完全靠进程退出。这一选择让启动/关闭代码非常短,代价是 Lite2D 不支持优雅关闭,对当时这个练手项目来说是可以接受的取舍。

06 · 双线程分工与数据流

我把游戏循环和 OpenGL 调用分到两个线程跑。主线程 60 FPS,渲染线程 120 FPS。主线程只负责「组织命令」,渲染线程只负责「执行命令」,两者之间就靠 _renderQueue->_queue 这一个 vector 传递 RenderCmd 指针。这样做的好处是:不管用户代码写得多慢,渲染节奏是独立的。

主线程 · 60 FPS Scheduler.update ActionMgr 驱动节点变换 Scene::drawScene → Node 递归 Sprite::draw 组装 RenderCmd_Quad Text/Button::draw 同理 TextureCache 命中返回 Texture2D* 产出:_renderQueue->_queue[] 渲染线程 · 120 FPS GLView::init 创建窗口 / makeContextCurrent 遍历 _queue 逐条 cmd->exec() glLoadIdentity / glBindTexture / glBegin glTexCoord2f / glVertex2f / glEnd glfwSwapBuffers / glfwPollEvents tex_pool 二级纹理缓存 消费:命令队列 RenderCmd*
图 2 · 双线程数据流:主线程产出命令,渲染线程消费命令

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 资源的创建/销毁都统一成了「主线程提需求,渲染线程代办」。

Part IV

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();

    }

};

一个 Sprite 的引用计数在一帧内的变化 t new Sprite autorelease() addChild 父->retain 使用 / 渲染 pool.clear() release 真 delete rc=1 仍 rc=1(池持有) rc=2 rc=1 父节点最后 release
图 3 · 引用计数在一帧中的变化轨迹

我对这个设计最满意的一点是:用户代码里 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;

}

Part V

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 前都会过一遍。

Part VI

渲染命令队列: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,只在渲染线程读写。

主线程 · TextureCache unordered_map<uint32, Texture2D*> 键 = xxHash32(路径字符串) 作用:避免对同一路径反复解码 命中:直接返回指针 未命中:投递 _func 到渲染线程 等待条件变量返回 Texture2D* 渲染线程 · tex_pool unordered_map<string, Texture2D*> 只在渲染线程读写,无锁 作用:持有 GL 纹理 id _func 内 glGenTextures glTexImage2D 上传像素 把 Texture2D* 插入 tex_pool
图 4 · 字符串键的主线程缓存 + 纹理 id 的渲染线程缓存,两级配合

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 + 批处理,接口层对外不用动。

Part VII

Sprite:从 Node 属性到 4 个顶点的变换链

18 · CPU 侧四步变换链

Sprite::draw 的职责是:拿到自身的世界变换 + 纹理的 4 个角坐标,算出屏幕上 4 个顶点,再填到 cmd 里。整个流程全 CPU,不依赖 OpenGL 的 modelview matrix:

Sprite::draw 的 CPU 变换步骤 步骤 1 旋转 cos/sin × anchor 步骤 2 缩放 × getGlobalScale 步骤 3 平移 + getGlobalPosition 步骤 4 归一化到 NDC ÷ windowSize · 2 − 1 产出: _v[0..7] ← 4 个顶点的 NDC 坐标(x0,y0, x1,y1, x2,y2, x3,y3) _tc[0..7] ← 4 个纹理坐标(根据格式不同,BMP 与 PNG 方向相反) _texId ← 底层 OpenGL 纹理 id _opacity ← 累乘 alpha
图 5 · Sprite 的 CPU 变换链

19 · NDC 归一化与翻转

Lite2D 的坐标系约定:窗口左下为原点 (0,0),宽 w、高 h,OpenGL 默认 NDC 是 [-1, 1]。所以每个顶点最后都要:x_ndc = x / w * 2 - 1y_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 游戏完全值得。这是典型的「用已经分配的对象的寿命,托管临时对象的寿命」。

Part VIII

纹理系统:BMP / PNG / JPG 三条解码路径

21 · Image:三路解码入口

图像这一层我写得最「杂」。render/Image.cpp 按扩展名分发三条独立路径,互相不依赖:

格式输出通道备注
BMP手写(无第三方)GL_BGR_EXT, 24bit要求宽高 2 的幂,否则 gluScaleImage 缩放
PNGlibpng + zlibGL_RGBA, 32bit使用 PNG_TRANSFORM_EXPAND 展开索引色
JPGlibjpegGL_RGB, 24bitglPixelStorei(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 以内,连一次碰撞都遇不到。

Part IX

动作系统: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 阶段拆出来放到前置就再也没出过问题。

Part X

事件系统:鼠标三态机与命中短路

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);

}

RELEASE 空闲 PRESS _began=true 可派发 MOVED ENDED 单次 按下 抬起 结束后回到空闲 持续按下 + 位移 → MOVED
图 6 · 鼠标事件状态机

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 密集时的遍历开销。

Part XI

文字渲染: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 里所有中文字面量,暂时没腾出手。

Part XII

动画: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" 动画各自有独立状态(当前帧、累计时间)但共享帧纹理 —— 这是我特意分开状态和数据的结果,不然一只小鸟会影响另一只小鸟的帧进度。

Part XIII

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

);

Part XIV

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;

}

主线程 · 发请求 HttpClient::get(url, cb) push 到队列 + notify_one 工作线程 · 处理 wait 醒 → curl_easy_perform 收字节 → 累积 body 完成 addFuncToMainThread lambda 排到 Director 待执行 主线程 · 下一帧 processOtherThreadFunc 触发用户回调 cb(body)
图 7 · HttpClient 异步闭环:请求在工作线程、回调在主线程

"回调必须在主线程"这个约束是我卡着不放的:因为回调里可能 new Sprite、addChild —— 而 AutoReleasePool 和场景树都不是线程安全的。把回调强制切到主线程后,用户代码就能像写同步代码一样自然,不用担心线程安全。这条规则和 iOS 的 dispatch_main_async 是同一个思路。

Part XV

音频与存档:两段不超过百行的实用模块

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),就能保证下一次打开读到正确的最佳分数。存档次数多了性能会有点损 —— 但个人小游戏场景里完全不是瓶颈。

Part XVI

FlappyBird:所有模块串成一条线

41 · FlappyBird 如何串起所有模块

我用 Lite2D 自己写了一个 342 行的完整 FlappyBird 作为自验小游戏。它触达了引擎几乎所有功能 —— 对想了解这套 API 怎么用的朋友来说,这份源码是最直接的入口。

Lite2D 实现的 FlappyBird 运行截图
图 8 · Lite2D 实现的 FlappyBird(MainScene 主菜单):背景 Sprite、标题、扇翅的小鸟(Animation fly)、new / exit 两个 Button
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 不多,但每一个通路都打通。想到"我要搞个按钮"、"我要让小鸟扇翅膀"、"我要存最高分"时,代码量和脑子里的流程基本一对一。

Part XVII

尾声 · 整体数据流与阅读顺序建议

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

如果你打算读一遍我的代码,下面是我推荐的顺序(按依赖自底向上,也大致是当年我一边画草稿一边写下来的次序):

  1. Ref.h/.cpp + AutoReleasePool — 先吃透对象生命周期,后面所有东西都建立在此
  2. Node.h/.cpp — 场景图 + 世界坐标,Sprite/Text/Button 只是它的子类
  3. Director + RenderCmdQueue — 主循环 + 双线程桥,这是我最花心思的一块
  4. RenderCmd(特别是 RenderCmd_Quad::exec) — 看到 gl 调用就见底了
  5. Sprite::draw — 连接"节点 → 命令"的桥
  6. TextureCache + Image + Texture2D — 纹理的三条路径,分开看就不乱了
  7. Action 家族 — 先看 Action.h,再看 MoveTo / Sequence
  8. EventDispatcher + Button — 状态机短路是我比较喜欢的写法
  9. Text + Animation — 本质都是 Sprite / Action 的扩展
  10. HttpClient / AudioEngine / PlayerSave — 三段辅助工具,谁都可以独立看
  11. FlappyBird/FlappyBird.cpp — 拿 342 行印证前面所有知识

附录

源码地址:https://gitee.com/dreamyouxi/Lite2D


— 全文 42 节 · 7 幅示意图 · 1 张游戏截图 · Lite2D 作者亲自撰写 —