CHUAN2 DEV ENGINE
996 正版授权研发中心 · 360 授权合作教学中心 · 抖音传奇直播合作授权 · 快手推广运营商授权
OFFICIAL LICENSED ACADEMY 查验官方授权证书 →
// 威海旷世互娱教学基地 · 技术文章
进阶实战游戏功能996引擎

同屏一百个怪为什么不爆内存:序列帧动画缓存体系

2026-10-03 07:13 作者:996 技术组 996引擎Lua教程传奇脚本进阶实战游戏功能996引擎

一个玩家角色有多少张图?8 个方向乘 8 个动作,再算上武器、头盔、翅膀、盾牌四个挂件层,两百多张起步。同屏一百个角色的老服, naive 算法内存直接上天。996 引擎没爆,靠的是 animation/actorSequFrameAnimManager.lua 里一套分动作粒度的缓存体系。今天把这套体系拆开,这是全引擎内存管理的精华所在。

一、一组动画一张档案:AnimDataDesc

管理器不直接管图片,管的是"动画档案"。每个动画资源一份档案:

lua
function AnimDataDesc:OnNew(animID, rawAnimID, sex, animType)
    self.animID         = animID        -- 档案主键:带性别等修正的最终ID
    self.rawAnimID      = rawAnimID     -- 原始资源ID:男女性别共图时就靠它复用
    self.animType       = animType
    self.timeStamp      = os.time()     -- 最后使用时间,LRU 驱逐的依据
    self.loadStateUnit  = {}
    for i = 0, MMO.ANIM_COUNT - 1 do
        self.loadStateUnit[i] = MMO.LOAD_STATE_UNLOADING   -- 每个动作独立登记加载态
    end

    self.animDelays     = {}            -- 每动作每方向的帧间隔表
    self.animFrameVecs  = {}            -- 每动作每方向的帧序列(含 cocos 对象)
    self.standPointX    = 0.5           -- 脚底锚点
    self.standPointY    = 0.5
    self.hasShadow      = true
    self.enlarge        = 1             -- 整体放大系数
end

两个设计决定值得细品。第一,animID 和 rawAnimID 分开:男女角色的图集大部分共用,档案按性别各有一份(锚点、动作表不同),但底层图集用 rawAnimID 复用同一份纹理。第二,也是全篇最重要的:loadStateUnit 是按动作下标开的数组——待机、走路、攻击……每个动作独立登记自己的加载状态。这意味着引擎能做到"只加载正在播的动作",没播到的动作状态停在 UNLOADING,内存里根本没有它。粗放的实现是整个角色图集一把加载,那两百多张图全在内存里蹲着,哪怕角色全程站着不动。

锚点字段 standPointX/Y 配合前面序列帧引擎讲的"脚底中心锚点",还有 enlarge 给坐骑放大、hasShadow 控制影子,都是档案级配置——同一套播放器,读不同档案播出一千种效果,播放器和数据彻底分手。

档案结构里还有个不起眼但很能打的成员:animDelays,按"动作 × 方向"存每个动作的帧间隔。为什么要按方向存?因为传奇序列帧的帧率从来不均匀——攻击动作的蓄力帧停得久、挥砍帧一闪而过,帧间隔表就是动画师手感的数字化。当年有个服反馈"战士攻击看着没力气",把攻击动作第 2 帧的 delay 从 40 毫秒改成 90 毫秒,蓄力感立刻出来了,一行表数据的事。这种手感参数如果写死在播放器里,每次调都得上代码;放进档案表,美术拿着表格就能自己磨。数据驱动的意义不是技术时髦,是把调手感的权力还给懂手感的人。

二、六态状态机:内存是按动作挤水分的

加载状态是一个六值状态机,迁移全靠两个函数:

lua
local InUses = {
    [MMO.LOAD_STATE_LOADING] = 1,
    [MMO.LOAD_STATE_INUSED]  = 1,
    [MMO.LOAD_STATE_FOREVER] = 1,
    [MMO.LOAD_STATE_FAILED]  = 1,
}

function AnimDataDesc:checkIsInUsed()
    for i = 0, MMO.ANIM_COUNT - 1 do
        if InUses[self.loadStateUnit[i]] then
            return true                 -- 任何一个动作还在用,整份档案不能放
        end
    end
    return false
end

function AnimDataDesc:setUnused()
    for i = 0, MMO.ANIM_COUNT - 1 do
        if MMO.LOAD_STATE_INUSED == self.loadStateUnit[i]
           or MMO.LOAD_STATE_FAILED == self.loadStateUnit[i] then
            self.loadStateUnit[i] = MMO.LOAD_STATE_UNUSED   -- 降级为闲置
        end
    end
    self.timeStamp = os.time()          -- 闲置起点,LRU 从这里计时
end

六种状态念一遍:UNLOADING 没加载、LOADING 加载中(防重复发起)、INUSED 有人引用、UNUSED 没人用可回收、FOREVER 永不清(主玩家常驻)、FAILED 加载失败。精髓在粒度:角色打了一架切回待机,攻击动作的图集几秒后就降级 UNUSED,管理器随时可以把这块内存挤出去,而待机的图集纹丝不动。 内存按动作挤水分,不是按角色挤——一百个角色的待机图高度同质(很多怪共用图集),实际内存占用比理论值低一个数量级。

FAILED 也参与降级是有故事的:加载失败的图如果不降级,checkIsInUsed 永远返回 true,整份档案永不回收,一个坏资源拖死一片内存。把失败也视作"可释放",是修过 OOM 的人才写得出的代码。

LOADING 状态防重复发起的机制也说一下,这是并发请求下的隐形护栏。十个怪同时上屏,每个都要同一份待机图,第一个请求把状态置成 LOADING,后面九个查到 LOADING 就只挂回调不再发起加载,图回来九个回调一起唤醒。没有这道闸,同一个资源会并发下载十次,流量翻十倍还容易互相踩。判状态、挂回调、等广播——三段式处理并发,跟网络协议那篇讲的合批思想是一回事:慢的事只做一次,等的人排队。

这个状态机还有个隐藏福利:checkIsInUsed 是逐动作扫的,所以"档案是否可放"的粒度天然精确到动作。你要做"战斗状态禁止回收任何资源"的激进策略,或者"副本里只留人形怪图集"的定向清理,都是在状态判断上加条件的事,不用动加载流程。策略和机制分家,这是状态机设计的隐藏红利。

mermaid
flowchart TD
A[请求播放 动作a 方向d] --> B{档案缓存有这份AnimDataDesc?}
B -->|有且该动作 INUSED/FOREVER| C[直接播, 缓存命中]
B -->|有但 UNUSED| D[UNUSED→INUSED 零加载复用]
B -->|没有| E[AnimDataDescCache 池化取档案]
E --> F[对应 Loader 加载: player/monster/hair/weapon...]
F --> G[LOADING→INUSED, 写 animDelays/animFrameVecs]
C --> H[动作播完且无人引用]
D --> H
G --> H
H --> I[setUnused 降级 + 打 timeStamp]
I --> J{内存超警戒线?}
J -->|是| K[按 timeStamp 驱逐最老 UNUSED]
J -->|否| L[留池待命]

三、400MB 警戒线与对象池

管理器构造里有一条硬杠:

lua
self._memoryInfo    = {}
self._memorySize    = 0
self._memoryWarning = 400 * 1024 * 1024    -- 400MB 内存警戒线

加载的每一份资源都在 _memorySize 里记账,逼近 400MB 就触发强制驱逐——从最老的 UNUSED 档案开始清,清到线以下为止。timeStamp 在 setUnused 那一刻刷新,就是给这条驱逐队列排序用的。这不是什么花哨算法,就是最朴素的 LRU,但配合按动作的粒度,效果拔群:低配机上告警触发的频率,比按整份档案管理的方案低一个量级。

档案对象本身也做了池化:

lua
function AnimDataDescCache:Alloc()
    local notification = table.remove(self._cache) or AnimDataDesc.new()
    return notification
end

function AnimDataDescCache:Recycle(task)
    table.insert(self._cache, task)
end

切图频繁时档案对象一秒建几十个,Lua 表的创建销毁是真实的 GC 压力,池子一来一回把这块开销抹平。这个 Alloc/Recycle 池和资源下载器那篇的 DownloadTaskCache 是同一个写法——引擎作者把"高频短命对象一律池化"刻进了肌肉记忆,你在自己代码里遇到同类场景照抄就好。

顺带把播放器的两个字段讲了,跟档案是配套的。actorSequenceFrameAnim 的 _animSpeed 是全局倍速——加速 buff 之下动作整体加快,就是改这一个数;_stopFrameIndex 能把动画钉在某一帧——死亡定格、石化定型全靠它。播放器暴露的旋钮越多,上层玩法越不用碰底层:定身类 Buff 改 stopFrame,狂暴类改 speed,播放器十年没大改过,玩法加了一茬又一茬,这就是好框架的样子。

下面这个演示把缓存面板摆上了台面:切动作看缓存命中(UNUSED→INUSED 零加载),闲置五秒自动降级,点"加载怪图集"把内存顶过警戒线,看最老的闲置档案怎么被 LRU 请出去——整个过程和你手机上发生的一模一样:

demo
fx-anim-cache-1003b

四、换装的真相:人物是六层贴纸

目录里一排 sequFrameAnimLoaderImp* 不是重复代码,是角色的"分件加载器":Player 管身体、Hair 管发型、Weapon 管武器、Shield 管盾牌、Wings 管翅膀,Monster 和 Npc 各管各的。一个走过来的角色,渲染时是身体层上叠四件装备层,各自独立加载独立缓存。

这个设计的妙处在成本结构:换武器只加载武器层(几十帧),身体层纹丝不动。老游戏里"换装一卡一卡"的服务,多半是把整角色当成一张图换,动一发牵全身。你要做"染色系统"、"时装混搭",正确姿势全在分件这条路上:加一个 Imp 子类,登记进加载器表,主流程不认识你的新零件。

rawAnimID 在这里再立一功:同款武器男女造型不同,但底图同一张,性别差异通过档案层的偏移和锚点配置消化,纹理只有一份。资源层面的去重做到这个深度,是几百万人在线喂出来的教训。

分件加载还有一个被低估的收益:分件缓存能独立降级。 低端机内存吃紧时,管理器可以先放掉翅膀、盾牌这些装饰层的缓存,保身体和武器——玩家看得出翅膀暂时消失,看不出游戏崩了。降级有先后、体验有底线,这是把"分层"思想贯彻到内存管理的典型样本。我们后来给低配机做的"轻量模式",本质就是给分件加载器加了优先级标签,两天的活,低端机崩溃率砍了七成。记住这个顺序:先分层,再有粒度,有了粒度才谈得上精细化的内存策略——一步到位的"智能内存管理"是不存在的,存在的都是分层分出来的余地。

五、实战案例:一次 OOM 崩溃的完整尸检

某服反馈"玩两小时必闪退",安卓后台日志显示内存峰值破 1.8G。尸检三步,每步一个教训。

第一步,拉内存曲线定位增速。发现每次进主城涨 60MB 不回落,按主城的怪物种类数估了一下,恰好等于"每个怪物整份档案全动作常驻"的量。嫌疑锁定:某二开模块把 BOSS 图集标成了 FOREVER——当初为了"切图回来快"私自改的。FOREVER 的语义是"主玩家级别的常驻",普通怪乱用就是内存钉子户。改回标准状态机后,主城稳态内存从 1.4G 降到 620M。

第二步,查 FAILED 滞留。日志里有 27 个加载失败的旧版本资源,全卡在 FAILED 没释放——正是前面说的那类问题,该服引擎版本较老没合这个修复。升级补丁后正常。

第三步,把警戒线接上告警。_memoryWarning 触发时往日志里写一条带 _memorySize 快照的记录,从此内存异常不用等玩家闪退才知道,后台曲线半夜就能看到。这次尸检的总结论:引擎的内存体系没有漏洞,漏洞全在"有人绕过状态机抄近道"。 状态机的每个状态都是一份承诺,FOREVER 尤其贵,签名之前先想清楚还不还得起。

给后来人的排查清单也沉淀下来了,遇到"越玩越卡越玩越崩"按这个顺序过:一看 _memorySize 增速曲线,涨不回落必有滞留;二搜代码里所有 FOREVER 的赋值点,逐个审有没有资格常驻;三查 FAILED 释放补丁打没打;四看同屏角色种类数——有些服的活动让一百种怪同屏,什么缓存策略都救不了,那得从玩法侧减配。四步走完还没线索,再来找我,这种情况三年遇不到一次。

六、常见疑问

问:为什么要自己记账 _memorySize,不直接问系统要内存占用?
系统查询慢且有滞后,记账是加减法零成本,驱逐决策要的就是实时的数。账实相符靠 Load/Release 两处严格对账,跟掉落物管理器的三张表同一个纪律。

问:切图很频繁,档案池会不会越滚越大?
池子有上限,超出的归还给 GC。档案对象很轻(几百字节),池化的收益主要在抖动期,稳态时它就是个普通复用队列,不用专门管。

问:动作加载中角色要播这个动作怎么办?
LOADING 状态就是为此设的:请求登记回调,资源就位后首帧立刻补播,玩家感知是"起手慢半拍"。千万别在 LOADING 时退化播待机然后忘记补播——那是"砍怪没动作"的经典 bug 来源。

问:timeStamp 用的 os.time(),客户端改时间会不会把 LRU 搞乱?
理论上会:玩家把系统时间往后拨一年,所有档案瞬间"闲置一年",驱逐顺序全乱。但乱的结果只是"清得不是最准的那批",功能不受影响,所以引擎没上服务器对时。这种"坏了也无害"的容错取舍值得学:不是所有数据都值得上强一致,看它坏掉的代价。

👉 完整课程入口:996 全套课程体系(千余节课录) | 想跟浮生梦老师系统学的,看 LUA 高并发商业架构路径。

作者履历与出处

本文由 996 技术组基于 996 引擎官方知识库与浮生梦老师课程体系整理。团队长期从事传奇类引擎 Lua 后端逻辑、客户端界面与商业版本交付,内容以官方知识库与真实项目为出处,按版本持续修订。

← 返回文章地图返回研学路径

幂尔框架 · 实战干货 · 接口调用

LATEST ARTICLES

全站技术干货持续更新:996 引擎 / Lua 实战帖,语法、参数与示例一篇讲透。进入文章地图 · 查看全部 →

进阶实战游戏功能

一把钥匙开全引擎的配置门:MetaValue元变量系统

全服喇叭喊话"恭喜 &<PLAYER_NAME & 获得 &<ITEM_NAME/2001 &",这条公告里的两个占位符是怎…

2026-10-03 11:55 996 技术组
进阶实战游戏功能

ui.按钮名就能拿到控件:LuaExtend元表的八十行魔法

读引擎 UI 代码时你一定会撞见这样的写法: self._quickUI.btnClose 、 SL:GetValue("x…

2026-10-03 11:30 996 技术组
进阶实战游戏功能

断线是谁先发现的:心跳三兄弟与切后台补发

玩家手机锁屏再解锁,游戏还在原地;地铁过隧道断网半分钟,回连后接着玩——这两件"理所当然"背后是 logic/gameWor…

2026-10-03 11:24 996 技术组
进阶实战游戏功能

全引擎的下一拍在这里敲:主循环Update解剖

每个引擎都有一个"心脏":每帧跳动一次、按固定顺序叫醒所有系统的主循环。996 引擎的心脏在 logic/gameWorld…

2026-10-03 11:17 996 技术组
进阶实战游戏功能

元宝数字为什么全屏同步:CostItemCell消耗格台账

玩家买一瓶药,背包角标的元宝、商店界面的元宝、充值面板的元宝三处数字同时跳——这个瞬间几乎没有玩家会注意到,但做客户端的人都…

2026-10-03 11:10 996 技术组
进阶实战游戏功能

每个东西都有专属座位:sceneGraph功能节点树

新接手引擎渲染层的人,打开场景会看到一锅粥:地图、角色、特效、血条、UI 全糊在一起。996 引擎的答案是把场景拆成一张"座…

2026-10-03 11:02 996 技术组