用 Flecs 构建渲染系统:数据驱动渲染管线的 ECS 实践 Data-Driven Rendering with Flecs
ECS 不替你画画,它只让你少操点心——从组件切分到视锥剔除、变换传播、再到 draw call 胶水层的一张完整地图
为什么渲染是 Flecs 的第一块验收石
讲 ECS 的教程,第一个真正跑起来的东西十有八九是渲染。一个三角形、一屏方块、然后一万个精灵在屏幕上乱跑。为什么偏偏是渲染?因为这玩意儿不撒谎。你的数据结构好不好、内存排布顺不顺、跨层交接有没有夹带私货,帧率表一秒钟就给你答案,连解释的机会都不留。
所以我把渲染放在这个系列的中间位置,不是随机排的。它是前面所有主题的一次总考试:组件怎么切是 [04] 的认知转型,查询怎么跑是 [07] 的查询体系,变换怎么传要用到 [08] 的 ChildOf 关系和 [11] 的 Observer 脏标记,渲染列表别塞进 ECS是 [06] archetype 抖动的活教材。你把这四关过了,Flecs 在你手里就算真入门了。
同时它也是下一篇 [13] 物理集成的预演。渲染和物理这两个外部系统,本质上干的是同一件事:把 ECS 世界里的数据,翻译给一个完全不懂 ECS 的世界(GPU、或者 Box2D 的刚体池)。这套翻译活儿的手感,是通用的。
但这里有个误会得先拆掉。很多人第一次接触 Flecs 的渲染示例,会觉得“哦,ECS 就是个更快的场景图”。错。ECS 根本不关心场景图长什么样,它只关心“哪些实体的哪些数据要被同一批遍历到”。场景图是你的业务概念,archetype 是他的物理现实,两者不是一回事。你把这两样东西搞混,就会写出那种“每个节点一个实体、每个 draw call 一个系统”的代码——能跑,但跑得像早高峰。
下面这四层,我按从内到外的顺序拆:组件怎么切、剔除怎么做、变换怎么传、最后怎么交到 GPU 手里。每一层我都给数值、给坑、给真实项目的对照。
第一层:组件切分——哪些数据该住进 ECS,哪些该留在门外
先说一个我翻过的车。早年间做 2D 项目,我把每个精灵的纹理句柄直接塞进了组件详情里。跑起来第一帧就崩了——Flecs 在 archetype 之间迁移实体时是整块内存搬家的,那个句柄被搬出了一份副本,最后析构的时候释放了两遍。查了整整一个下午。教训就一句话:GPU 资源句柄不是数据,是贷款,你要还给它的主人,而不是复制它。
所以先立两条筛选原则,简单到你可以在白板上写:
原则一:生命周期跟着实体走的,进 ECS。位置、速度、血量、当前动画帧——实体死了,这些数据就该跟着死。这类东西天然属于组件。原则二:要被“一批一批查找”的,进 ECS。渲染最典型:你要一次性拿到“所有可见的、带网格的、在不透明层的实体”。这种批量访问正是 archetype 连续内存的主场。
反过来,不进 ECS 的东西清单也很清楚:顶点缓冲、纹理像素、着色器程序、帧缓冲对象、字体图集。这些是资产,有自己独立的生命周期(可能被十个敌人共享,可能跨场景存活),实体里只放一个 ID。我常跟人说,实体是演员,资产是化妆间。你总不能把整套化妆台搬上台,然后跟灯光师说“快,找找眉笔放哪了”。
然后是粒度问题,这是新手最容易拧巴的地方。Flecs 的组件划分本质是回答“谁和谁需要被一起遍历”。判断标准就一条:如果你从不把两个字段放在一起读,那它们就不该住在同一个组件里;反过来,如果你每次读其中一个都要顺手读另一个,拆开就是自找麻烦。
举个具体的:位置、旋转、缩放。这三样该合成一个 Transform 组件,还是拆成三个?我的答案是合成。理由是渲染提取和变换传播每次都要三个一起用,拆开只会让 archetype 数量膨胀——而且很多静态物件(比如一块地面)只有位置没有旋转缩放,会额外制造出一批“只有 Position”的碎片 archetype。碎片一多,[06] 里讲的按块遍历就变成了按行扫描,性能直接掉一个档。
但有一种情况值得拆:2D 弹幕射击。那种场景里几千个实体只有位置、没有旋转没有缩放。这时候一个单独的 Position 组件(两三个浮点)比拖着一个 10 浮点的 Transform 走要划算得多,内存带宽省下来是实打实的。所以这里没有教条,只有“你的实际访问模式”。
把渲染相关的组件摆成一张表,大概长这样:
| 组件 | 装什么 | 谁写 | 谁读 | 建议 |
|---|---|---|---|---|
| Transform | 本地位置/旋转/缩放,10 个浮点 | 游戏逻辑、物理、动画 | 变换传播系统 | 合成一个,别拆三个 |
| GlobalTransform | 世界矩阵 4×4 或压缩的 3×2 | 仅变换传播系统 | 剔除、渲染提取 | 只读,业务里手改必出鬼 |
| MeshRef | 网格资产 ID,32 位整数 | 生成逻辑、换装 | 渲染提取 | 放 ID,绝不放顶点 |
| MaterialRef | 材质资产 ID | 生成逻辑、换装 | 渲染提取、合批 | 同上 |
| BoundingSphere | 球心 + 半径,4 个浮点 | 网格加载时烘算 | 剔除 | 球心可省,用世界位置替代 |
| RenderLayer | 不透明/透明/UI/阴影位掩码 | 生成逻辑 | 排序、剔除 | 掩码比多个 Tag 更省实体数 |
| ViewVisibility | 本帧可见位 + LOD 档位 | 剔除系统 | 渲染提取 | 和业务可见性分开,下面细说 |
| Static | 空 Tag,零字节 | 变换系统自动加 | 变换传播 | 值得用,因为它参与 archetype 划分 |
| CastShadow | 空 Tag | 生成逻辑 | 阴影剔除 | 同上,纯标记一律用 Tag |
这张表里我想单独夸一下 Flecs 的 Tag。零尺寸组件,不占一字节内存,但它参与 archetype 划分——这意味着“所有带 Static 的实体”自成一个连续块,查询它们几乎是白送的。所以凡是纯标记(静态、可投射阴影、正在播放、已死亡、需要序列化),一律做成 Tag,别做成带布尔值的组件。一个字节也是字节,一万个实体就是一万个字节,何况它还会拖慢缓存行的有效利用率。
还有一个 Flecs 独有的甜头:用 IsA 关系做初始值模板。想象一百个敌人共享同样的渲染配置——同款网格、同款材质、同款包围球半径。你不需要给每个实体都写一遍这些字段,挂一个原型关系上去,读不到的时候自动往上找。改一处材质,一百个全变。这在渲染上特别顺手,换皮肤、做变体、调试时临时改参数,全都省事。
第二层:视锥剔除——你的查询就是剔除器
先说结论:大部分独立游戏不需要空间索引。真的。这句话可能会让一些读了太多 GDC 演讲的人不舒服,但我必须说。你在屏幕上有 300 个精灵的时候,做一套八叉树 + 桶管理 + 增量更新,是在给自行车装涡轮增压——听起来很凶,蹬起来更累。
剔除分两段:宽相位找“可能看见的”,窄相位判“确定看不见的”。窄相位很简单,包围球对六个视锥平面求有符号距离,只要某个平面上球心到平面的距离小于负半径,就直接剔掉。这一步在 Flecs 里就是一次查询遍历,因为 Position、BoundingSphere、RenderLayer 这三个组件相邻存储,CPU 的预取器会感谢你。一万个实体跑一遍,在普通 x86 上大概 0.1 到 0.2 毫秒,比你在 Excel 里改三行公式还快。
真正需要动脑子的是宽相位。三条路,我按推荐顺序排:
路线 A:不建索引,全遍历。适用上限大概一万到两万个实体。优点是零维护成本,实体一动不用管任何桶的边界。缺点是这个方案没有“扩展性故事”可讲——但你如果做的是个独立游戏,这个故事你可能压根不需要。
路线 B:组件里存桶编号。均匀网格,桶的边长取平均物体直径的两倍左右。实体在组件里存一个整数桶号,剔除时先判断桶(一个桶的包围盒一次就够),桶可见再逐个判球。改动轻,查询友好,是性价比最高的一档。麻烦在于桶号要在物体移动时更新,那就得挂一个观察者去监听位置变化——这正是 [11] 那篇的地盘。
路线 C:用关系把实体挂到桶实体上。这是 Flecs 最“原教旨”的做法,桶是实体,物体通过“属于”这类关系挂上去。好处是层级天然成立,剔除一个桶等于剔除它下面所有东西,而且关系的遍历在 Flecs 里被优化得不错。坏处也很实在:关系变更会触发实体在 archetype 之间迁移,一个快速移动的实体会在桶之间反复横跳,每次跳都是一次内存搬家。如果你的游戏里有大量每帧跨桶的高速物体(子弹、粒子),这条路会让你哭。用它之前,先量一遍迁移的代价。
这里我想插一个容易忽略的设计:把可见性拆成两层。一层是业务可见性,代表“这个物体被游戏逻辑隐藏了”(比如隐身、剧情事件);另一层是本帧可见性,代表“这个物体这一帧刚好落在视锥外”。它们是两码事,一个由玩法决定,一个由相机决定。合成一个布尔值会出事——你会分不清“敌人隐身了”和“敌人被我转头甩掉了”,逻辑上就很容易写出“转个身敌人就永远消失”的 bug。Bevy 在这件事上做得很干净,它把 Transform 和 GlobalTransform 分成两级,可见性也分成两级,思路是一致的:把“谁说了算”和“现在是什么”分开存。
LOD 分档跟着剔除一起做最划算,因为距离和屏幕占比都已经算出来了。我一般的分法是按屏幕高度占比而不是绝对距离——绝对距离在不同 FOV 和不同分辨率下完全不通用,你换个相机参数整套数值就废了。
关于剔除频率,有个特别好用的小技巧:不是所有东西都值得每帧剔。主相机每帧做;阴影相机隔帧做;远处的小物件每三到四帧做一次然后插值复用。因为远处的物体在屏幕上移动得慢,隔帧的误差肉眼根本看不出来,而省下的是实实在在的 CPU 时间。这招在移动端特别好使,我靠它在某项目上给 CPU 主线程腾出了将近一毫秒。
最后讲个我自己的笑话。有次给一个塔防做剔除,空间哈希、桶管理、增量更新,一套下来写得挺得意。跑完 profile 一看:剔除本身花了 1.2 毫秒,场景里一共 400 个精灵,全画面遍历也就 0.03 毫秒。我当时的心情,就像花三个月装修了一个两平米的储物间。
第三层:Transform 层级与 ChildOf 的矩阵传播
世界矩阵等于父的世界矩阵乘以本地矩阵。这句话写在小学生的数学课本里都成立,但工程上怎么算得快,是另一回事。
Flecs 在这里给了你一个非常自然的抓手:ChildOf 关系。挂上去就是一个树,不用自己维护子节点列表,不用自己维护引用计数,查询“某个实体的所有子节点”是框架层面的事。渲染层级、角色骨骼、UI 布局、场景分块,全都能用这一套。这也是为什么 [08] 关系那篇我写得那么长——它是 Flecs 区别于其他 ECS 的命根子。
但 ChildOf 只给了你结构,没给你顺序。而矩阵传播是个严格依赖顺序的活:父必须先算完,子才能算。这点特别容易踩——你写了遍历所有带 Transform 的实体的系统,跑起来发现胳膊肘会滞后一帧,或者偶尔抖一下。原因就是遍历顺序不保证父在前。archetype 的存储顺序是按组件组合排的,不是按你的场景树排的。
解法有三种,我按实施难度排:
解法一:给每个实体存深度。挂父的时候顺手算一次深度(父深度 + 1),传播时按深度升序遍历。Flecs 的查询支持排序遍历,所以这一步不需要你自己建表。代价是层级变动时要更新深度,但层级变动本来就少。
解法二:从根节点向下做一次递归下降。拿到所有“没有父节点”的实体,逐个往下走。结构上更干净,但递归在深度大的时候不好控,而且跨 archetype 跳来跳去会打断预取。
解法三:两趟法。第一趟算所有父的世界矩阵,第二趟再算子。只适用于固定两层结构(比如“地面 + 摆件”这种),三层以上就不灵了。
我一般用解法一,稳。
接下来说性能。这个是能拉开数量级的地方,值得单独讲讲。
方案 A:每帧全量重算。十万个实体,每次一个 4×4 矩阵乘法加上写入 GlobalTransform,单线程保守估计两到四毫秒。这钱花得冤不冤?看你场景静不静。一个城市建造游戏,百分之九十的建筑一年都不动,你每年给它重算一次干嘛。
方案 B:脏标记 + 传播。用 Observer 监听 Transform 的写入,给实体打一个 DirtyTransform Tag。传播系统只处理带这个 Tag 的实体,同时要处理它们的全部后代(因为父动子必动)。稳定态下变化率通常在百分之五以内,耗时掉到零点几毫秒。我给个经验区间:一万实体、百分之五变化率,两百到五百微秒;十万实体、变化率百分之五,一到三毫秒(因为子树的连带更新会放大工作量)。
方案 C:静态烘焙 + 脏标记。连续三十帧没动过的实体自动加 Static Tag,它的世界矩阵一直缓存着,永远不再参与计算,只有被显式唤醒才回来。这一招下去,稳定态的耗时基本可以忽略不计,零点一毫秒以内。
关键细节:脏标记的清除时机。千万别边遍历边清——你一清,子节点就找不到父的脏状态了,传播会断。正确做法是整个传播流程跑完之后,统一在帧末清一遍。这个坑我踩过,表现是“偶尔某个物体的子节点位置不更新”,概率大概百分之几,特别难复现。
真实案例对照。Bevy 的变换传播做得很有意思:它把变更检测直接下沉到查询层,只有本帧被写过的 Transform 会进入传播流程,而且它严格排序保证父在前。代价是,变更检测本身不是免费的——社区里为这事吵过一轮:很多人在生成特效时习惯给每个子弹挂一个粒子子节点,结果发现这种“大量短命父子结构”反而成了瓶颈,因为每个新建的实体都要走一遍变更流程和排序。最后的结论是,纯世界坐标的东西别硬套父子,父子结构留给真正需要继承的(角色骨骼、UI 布局)。层级是给继承用的,不是给组织用的。这个区分我记到现在。
另一个值得说的是《守望先锋》那套架构(GDC 2017 那场著名的分享)。他们的做法是极度克制:渲染相关的组件就那么几个,全部围绕“要被批量交给 GPU”这个目的设计,变换也没有搞特别复杂的继承体系,而是尽量扁平化,然后走并行任务批处理。他们拿通用性换了可预测性,而且明确指出这个取舍是为了让成百上千个英雄、技能特效在一帧内稳定输出。这个思路对独立开发者其实更有借鉴价值——你没那么多实体,就更该走极简路线,把复杂度留给真正有收益的地方。
第四层:胶水层——把 ECS 数据翻译成 draw call
到这里,ECS 的活儿干完了。屏幕上还是黑的。因为 ECS 不认识 GPU,它不认识 bgfx,也不认识 sokol,它只认识内存里排好的组件。最后这一步翻译,得你自己写。
这一层的设计原则只有一条,但违反它的人极多:ECS 只负责“查出谁要画”,绝不负责“怎么画”。
具体怎么落?每帧走三段:
第一段,提取。跑一个查询,条件大概是“有 GlobalTransform、有 MeshRef、有 MaterialRef、ViewVisibility 为真”。把每个命中实体压成一条渲染项——网格 ID、材质 ID、世界矩阵、排序键、层,全部塞进一个紧凑的结构里。目标尺寸是 64 字节以内,也就是一条 cache line 装一个。这一步的意义是把“分散在多个组件里的信息”聚成“一个能连续扫描的扁平数组”。后面所有工作都不再碰 ECS 世界。
第二段,排序与合批。先按层切(不透明、透明、UI、阴影),层内再按流水线状态排(着色器、材质、网格),不透明层按状态排就够,透明层必须按深度倒序。排序键我一般拼成一个 64 位整数:高几位放层,中间放流水线编号,低几位放深度。一次基数排序搞定,比比较排序快得多,而且是稳定的。
第三段,提交。按合批单元发 draw call。这里有三条路可选:相同网格加相同材质,走实例化,一次 draw call 画一片;相同材质不同网格,走静态顶点合并,前提是这些网格不会单独动;都不满足,老老实实一个一个发。
关于实例化的阈值,我给个具体数字:同类实例少于四个就不值得开。这不是拍脑袋,是因为实例化的 CPU 开销(准备实例数据、切换渲染状态)在小批量下会超过它省下的提交成本。你可以在自己项目里试着把阈值从 1 调到 8,量一下帧时间,一般能看出一个拐点。
这里必须点名一个反模式,它是 archetype 抖动的头号现场:把每条 draw call 做成一个实体。听起来很数据驱动对吧?每帧清空渲染实体、重新生成一批、然后跑个系统去提交。结果是每帧几千次实体创建销毁,每次都要在 archetype 之间搬家,[06] 里讲的碎片化全给你演一遍。我见过一个项目因为这个,稳定态帧时间从 6 毫秒涨到 22 毫秒,找了两周。渲染队列这东西的正确归宿是一个外部数组,它不属于世界,它只活一帧,用完就扔。
Flecs 官方仓库里带的那几个图形示例(raylib 和 sokol 的集成),形态都极其朴素:查询一波,写进数组,交给后端。第一次看会觉得“就这?”,看第二遍才明白——这恰恰是对的分层。框架作者没有替你搞一套渲染抽象层,因为那玩意儿一旦做进来,Flecs 就不再是数据容器,而变成了半个引擎,边界就糊了(这个话题 [27] 还会展开吵)。
说一个真正把这条路走到极致的外部参照:Unity 的 DOTS Entities Graphics。它把整个世界翻译成 GPU 数据的方式,不是“每个实体一条渲染项”,而是“每个 chunk 一条渲染项”——直接拿 archetype 数据块里连续的世界矩阵去喂底层渲染接口,中间几乎不做逐实体转换。剔除结果也是 chunk 级的可见列表。收益是惊人的:十万个小物件能在中端手机上跑起来,帧时间还稳。代价同样惊人:这是一个封闭世界,材质和网格的绑定基本是静态的,你想动态换装就得走另一套系统,自定义着色器数据的空间也被框死了。
这个案例最值钱的地方,是它验证了一件事:ECS 到渲染的最优中间形态,往往不是实体级,而是块级。Flecs 的 archetype 结构([06])天然支持这个思路——如果你的渲染项不依赖逐实体的特殊数据,完全可以按块批量生成,一次处理一整块组件数组,连那 64 字节的渲染项都省了。这是进阶玩法,不是起步姿势,但值得你心里有数。
最后提一句渲染系统的调度。它要跑在变换传播之后、提交之前,在 Flecs 的管线里这是一条明确的依赖关系([09] 那片的地盘)。渲染提取依赖 GlobalTransform 是最新一轮,千万别让并行调度把它和变换系统排到同一批里,否则你会看到物体一帧前的位置——俗称“鬼影”。
参数化设计工具:一张能直接抄的调参表
下面这张表是我这些年攒下来的默认值。它不是真理,是起点。每个数字后面都写了“什么情况下往上调、什么情况下往下调”,方便你自己找手感。
| 参数 | 建议区间 | 调高会怎样 | 调低会怎样 |
|---|---|---|---|
| 包围球半径冗余系数 | 1.1 ~ 1.3 | 少剔除,安全但浪费 | 物体在屏幕边缘突然消失,LOD 跳变明显 |
| 空间网格边长(相对平均物体直径) | 1.5 ~ 2.5 倍 | 桶变大,桶内遍历变多 | 桶太多,桶级判断的收益被稀释,跨桶迁移频繁 |
| 主相机剔除频率 | 每帧 | — | 相机快速运动时出现漏画 |
| 阴影相机剔除频率 | 每 2 帧 | — | 阴影跟着相机抖动 |
| 远景小物件剔除频率 | 每 3 ~ 4 帧 + 插值 | — | 远处物体边缘发飘 |
| LOD 档位(按屏幕高度占比) | L0 >15%,L1 5-15%,L2 1.5-5%,L3 <1.5% 剔除 | 高低模切换突兀 | 切换太频繁,产生“呼吸感”闪烁 |
| 静态判定帧数(连续未变化) | 20 ~ 60 帧 | 静态化太慢,启动期白算 | 频繁切换 Static 状态,archetype 反复迁移 |
| 层级深度上限 | ≤ 5 层 | 传播开销指数上升,排序成本吃不消 | —(太浅其实无所谓,只是表达能力弱) |
| 实例化启用阈值(同批数量) | 4 ~ 8 个 | 小批量走实例化,CPU 反被拖累 | 大量重复物件各发一次 draw call |
| 每帧 draw call 预算 | 移动端 100-200,主机/PC 1000-2000,高端 3000+ | 超过即掉帧,且掉得很陡 | — |
| 渲染项结构大小 | ≤ 64 字节 | 一个 cache line 装不下,提取和排序都变慢 | 塞不下排序键,只能走二次排序 |
| 单帧最大渲染项数 | 按你后端能力定,务必设上限 | 内存峰值飙升 | 大规模场景里被裁掉一部分物体,出现“空窗” |
这张表怎么用?我的建议是先全部用默认值跑通,然后只调一个参数、跑一遍、看帧时间曲线。别一次调五个,调完你根本不知道是哪个起的作用,这是性能调优里最常见的自欺欺人。
另外一个容易忽略的表,是“组件拆分决策表”。当你在纠结某个渲染字段该不该单独成组件时,问自己三个问题:这个字段会不会被单独读写?它出现的频率和其他字段一致吗?它缺失的时候会不会产生大量只有部分组件的实体?三问两“是”就该拆,两个“否”就该合。回答完这三个问题再动手,比事后重构省事得多。
双路径:你是哪一档的开发者
初级路径:先把画面对上,别管优化。
第一步,反向验证你的组件表——先只放三个组件:Transform、MeshRef、MaterialRef。能画出东西就赢了,别一开始就 GlobalTransform 分两级、可见性分两层,那些是第二步的事。
第二步,写渲染提取。一个查询、一个数组、一次提交。数组放外面,不塞进 ECS,这条从头就守住。
第三步,加变换传播。此时不需要脏标记,每帧全量重算就好。层级少于三层的话,你可以直接递归,别搞排序。先让它对,再让它快,顺序反了你会有一万个 bug 要查。
第四步,加剔除。先从最朴素的包围球对六个平面开始,连空间索引都不建。跑到你发现剔除确实成了瓶颈,再考虑建桶。
第五步,才是优化。加脏标记、加 Static、上实例化、分 LOD。到这里你已经有可测的基线了,每次优化都能量出效果,不会陷入“我觉得变快了”的自我安慰。
按这条路走,一个能跑的 2D 渲染管线,周末两天足够。我见过有人卡一个月,全卡在第一步就想上多线程剔除。
进阶路径:把交接面压到最薄。
如果你已经跑通了,接下来值得啃的三块硬骨头:
一块是块级提交,参考 DOTS 那条路。不再逐实体生成渲染项,而是按 archetype 块直接批量生成,甚至可以在组件内存布局上做手脚,让世界矩阵的排列正好等于 GPU 顶点缓冲想要的排列,一次内存拷贝就送到显存。这条路能把提取阶段的 CPU 开销压到几乎看不见,但要求你的渲染数据极其规整,一旦有特例就得打补丁。
第二块是并行剔除。Flecs 的多线程调度([10])可以把可见性计算按块分发到多个线程,然后归并。要点是:每个线程只写自己那一块的 ViewVisibility,别跨块写,否则你要处理一堆同步。线程数和块大小的比例大概在 1 比 8 到 1 比 16 之间比较舒服,太小的块会让调度开销吃掉收益。
第三块是间接绘制。把所有渲染项一次性写进 GPU 缓冲,让 GPU 自己决定怎么画,CPU 每帧只提交一次。这是现代渲染管线的主流方向,也是最考验“数据规整度”的方案——你的 ECS 世界越干净、组件切得越准,这条路就越顺。我私心觉得,Flecs 的 archetype 结构和这条路是天生一对。
争议与常见坑:五个让人半夜爬起来改代码的地方
坑一:GPU 句柄塞进组件。组件会被复制、搬移、克隆。你把一个裸句柄(纹理、缓冲、着色器)放进去,就等于给了很多东西一个“释放同一块内存”的许可。轻则漏内存,重则崩溃。规矩很简单:ECS 里只放整数 ID,真正的资源归资产库管,谁创建谁释放。
坑二:把渲染队列当作实体集合。每帧创建几千个实体来代表 draw call,然后跑系统提交。这是 archetype 抖动的教科书现场,帧率会以肉眼可见的方式崩。渲染队列必须是普通数组,活一帧,扔一帧。这条我在前面说了三次,因为它值得说三次——它是新手在这个领域最容易犯、也最难自己发现的错。
坑三:变换传播不做拓扑排序。表现是子节点位置滞后一帧,或者偶发抖动。原因前面说过:archetype 的遍历顺序和你的场景树顺序无关。加个深度字段按深度排序,问题立刻消失。
坑四:剔除结果写回 ECS。每帧给上万个实体写一个可见性布尔值,这会让你精心设计的连续内存布局彻底白费——因为写入量本身就吃满了带宽,而且会持续触发变更检测。可见性属于“临时结果”,它的正确位置是提取阶段之前的那个扁平数组,而不是 ECS 世界里。
坑五:以为 ECS 会自动帮你合批。不会。ECS 只是把数据排得整齐,让你自己合批的时候更省事。它不替你决定哪些东西能合、按什么顺序画、什么时候开实例化。这些是渲染工程师的活,ECS 帮不上忙——但它能让你少查几次内存。
再说一个更宏观的争议:用 ECS 做渲染,到底比传统的场景图强在哪?
反方的声音非常实在:场景图有清晰的父子结构、有明确的遍历顺序、有现成的事后排序,你要做个叙事驱动的小品游戏,场景图五分钟就能摆好,ECS 要写一堆组件和系统。他们说得对。实体少于一千、每帧变化少于一百的游戏,用 ECS 做渲染纯属自虐。这话我说得很直接,因为我看过太多人为了“正确”而选型,最后卡在基建上没做完游戏——游戏没做完,架构再优雅也是零分。
正方也不是没道理:当你的实体到了几万、当你要在同一种遍历里同时处理逻辑和渲染、当你需要频繁增删物体而不是摆好后就不动,ECS 的优势就不是“快一点点”,而是“能不能做出来”的区别。大规模粒子、弹幕、城建、生存模拟,这些类型的实体数量和生命周期模式,天然和 ECS 契合。
我的立场是:别拿 ECS 当宗教,拿它当工具。它在你需要批量处理几万个东西的时候是神器,在你需要精心编排一场三分钟过场动画的时候是累赘。同一个项目里,两者是可以共存的——渲染走 ECS,剧情演出走一段手写的脚本化流程,没人规定你必须全栈原教旨。
FAQ
Q1:Transform 和 GlobalTransform 到底该谁写谁读?我能不能只存一个?
只存一个是能跑的——如果你没有层级,只有一个世界坐标,那确实一个就够,省一半内存。但你一旦有了父子结构,就必须分开:本地坐标是“输入”,世界坐标是“输出”。分开的好处是渲染提取、剔除、物理这些系统全都可以只读世界坐标,一行都不碰本地坐标,读取路径干净,并行也安全。混合读写是这类系统出 bug 的高发区。
Q2:Flecs 能直接对接 GPU 吗?有没有现成的渲染模块?
不能,也没有。这不是缺点,是设计边界的主动选择——[27] 那篇会专门聊这个。Flecs 是个数据容器,它能帮你把待渲染的数据组织得整整齐齐,但它不认识 bgfx、不认识 sokol、也不认识 Vulkan。渲染模块自己写,或者去找社区里别人写的胶水层。别期待框架替你解决这一层,那本就不是它的职责。
Q3:我的材质变体特别多,用 IsA 关系做继承合适吗?
合适,但要克制层级深度。IsA 是“读不到就往上找”,挂一层两层的变体(比如基础材质→金属材质→生锈金属)体验非常好,改一处全变。但如果挂到五六层,每次读取都要走一遍查找链,而且 archetype 的划分会变得很碎。我的经验是继承链别超过三层,再深就该把公共部分拍平成一个具体值了。
Q4:小场景到底要不要做视锥剔除?
先量再说。你可以写个临时的计数器,算一下每帧遍历全部渲染实体要多久。如果这个数字小于 0.1 毫秒,别做剔除,做出来也是负收益。我见过的最小场景,屏幕上就四十个东西,剔除代码比渲染代码还长。记住一句话:优化是为了解决你实际遇到的问题,不是为了解决你想象中的问题。
Q5:我按这套做了,但比原来的 OOP 渲染还慢,为什么?
九成的情况是三个原因之一:渲染队列做成了实体集合(见坑二);变换传播每帧全量重算又没加 Static;或者组件切得太碎,archetype 数量爆炸导致遍历时反复跳块。排查顺序就是这三个,从后往前查。如果三个都不是,那就量一下提取阶段的耗时——很多人栽在“每个实体都重新计算一次包围球”这种看着不起眼的地方。
说到底,ECS 的加速从来不来自“框架牛逼”,而来自你把数据排成了 CPU 喜欢的样子。排得不整齐,它和 OOP 一样慢,甚至更慢,因为你还多了一层抽象。
留一个问题给你
写完这一整篇,我最想说的其实是那句被引用了无数遍、但真做到的人不多的话:ECS 的收益,百分之八十来自你切组件的功夫,百分之二十才来自框架本身。渲染这条线尤其明显——同一套 Flecs、同一套硬件,两个人写出来的帧时间能差三倍,差的不是 API 用得熟不熟,是组件切得准不准、渲染队列放得对不对、脏标记清得是不是时候。
留个开放性的话题给你琢磨:如果未来渲染管线全面走向 GPU 驱动、CPU 只需要提交一次缓冲就撒手不管,那么 ECS 在这个链条里的位置会往哪挪?它会变成一个更纯粹的“数据编排层”吗?还是说,随着提取和排序全下沉到 GPU,我们这些在 CPU 上抠剔除、抠合批的功夫,会像当年的固定管线一样,变成一段美好的回忆?
我没有标准答案。但我知道一件事:每一次渲染架构的代际更替,最后活下来的都不是最会写代码的人,而是最清楚“数据该长什么样”的人。这个能力,ECS 恰好是练得最狠的地方。