普通视图

发现新文章,点击刷新页面。
昨天以前云风的 BLOG

监视 Lua 对象的修改

作者 云风
2024年6月11日 10:12

我正在制作的游戏 demo 中,所有对象逻辑上都存在于二维空间,但在 Ant Engine 中通过 3d 渲染方式绘制出来。

我希望有一组简便的 API 方便我控制这些对象的渲染,只是控制它们的位置以及在 Y 轴上的旋转量。Ant Engine 是用场景组件来控制 entity 渲染时的空间状态,但场景节点使用的是 3d 空间的 SRT 即缩放、旋转、位移。而我只需要控制其中的两个坐标轴上的空间位置以及一个旋转轴上的旋转量,直接修改 SRT 太不方便了。而且,使用引擎时,还需要每帧标记被修改过的场景组件对应的 entity ,这也很麻烦。

在 ECS 结构下,最简单的方式是为这些 entity 创建一个额外的组件,里面有 x y r 三个值。通过一个 system 把它们转换到场景节点在 3d 空间下的 SRT 组件中。但如果每帧都全部转换一次显得多余,毕竟大部分 entity 不是每帧都会发生变化的。

我用了一个简单的 Lua 技巧来方便开发,下面便是代码:

local monitor = {}

local function new_type()
    local changes = {}
    local function touch(obj, k, v)
        local raw = obj.__raw
        changes[raw] = true
        raw[k] = v
        obj.__newindex = raw
    end
    local function new (obj)
        obj.__index = obj
        obj.__newindex = touch
        changes[obj] = true
        return setmetatable({ __raw = obj }, obj)
    end
    local function next_raw(t, key)
        local nkey, nvalue = next(t, key)
        if nkey then
            nkey.__newindex = touch
            return nkey, nvalue
        end
    end
    local function pairs()
        if next(changes) then
            local t = changes
            changes = {}
            return next_raw, t
        else
            return next, changes
        end
    end
    return { pairs = pairs, new = new }
end

local types = setmetatable ({}, {
    __index = function(self, name)
        local t = new_type()
        self[name] = t
        return t
    end })

function monitor.new(typename)
    return types[typename].new
end

function monitor.pairs(typename)
    return types[typename].pairs()
end

TEST = true

if TEST then
    local a = monitor.new "test" { x = 1, y = 2 }
    local b = monitor.new "test" { x = 10, y = 20 }

    local function flush()
        print "====="
        for obj in monitor.pairs "test" do
            print(obj.x, obj.y)
        end
    end

    a.x = -1
    flush()
    b.y = -20
    flush()
    local c = monitor.new "test" { x = 0, y = 0 }
    flush()
else
    return monitor
end

从最后的 test 代码可见:我们可以通过 monitor.new "typename" {}创建一个逻辑上有 x y 坐标的 lua 对象,它并不需要是 ECS 的组件,在和 ecs 结合使用的时候,可以把 eid 也放进对象里(在后面遍历的时候,可以对应到 ecs 中的 entity )。当我们后续修改这些对象时,会把修改过的对象标记在内部一张表中。

通过 for obj in monitor.pairs "typename" 可以遍历所有最近修改过(及新创建)的对象。

一个游戏的点子

作者 云风
2024年6月7日 12:53

宅在家里一个月了。一直在想,如果不考虑迎合市场,不顾及销量,到底应该做一个怎样的游戏才能让自己在制作过程中得到满足。

过年前曾经参加过一次老同事聚餐。组织者说,这屋子坐的都是做游戏的老人了,程序、策划、美术全齐了,还都是不差钱的主。大家要不要凑个局,想想做个啥游戏出来?接下来是一阵沉默,直到有声音说,“我没什么想法”,饭桌上的人纷纷点头,转移了话题。

前段参加一个独立游戏活动,见了些老朋友。有位同学做游戏很多年了,说起这些年的经历,入行头几年是给老板打工,接下来开了家小公司自己做,没赔钱也没赚钱。但干下来感觉变成了给员工打工。为了可以持续发出工资,每次立项都很匆忙,结果还是在不喜欢的游戏项目上耗掉了太多时间。现在干脆把团队安顿好,一个人出来,好好想想到底要做什么。

可见,想清楚做什么很难。单独一人的状态也很难得,没有太多的外界干扰,不为了做事而做,可以慢慢来。

首先,我想做一款游戏,这毋庸置疑。玩游戏是我这些年最大的爱好。光在 steam 上这些年就花掉了上万小时,switch 上也有几千小时。我能在制作游戏的过程中获得我要的东西。

其次,做一款游戏的目的不是为了收入。我对物质生活要求极低,不需要花钱满足欲望。除非需要雇人一起做游戏,不然制作游戏的开销只是自己家庭的日常开销,而我这些年的积蓄已够过完余生。我喜欢的游戏都不需要太复杂的美术资产,这方面并不需要额外的投入。

另一方面,我也不需要用游戏讨好玩家来获得成就感,不需要用一个产品来证明自己,这些成就感的体验都已有过,不是我想追求的东西。

所以,我所需要的是制作过程带来的持续体验,让自己觉得自己在做一件有意义的事。我所喜欢和擅长的其实是:认清问题,解决它们。


最近玩了很多游戏。有一直想玩但之前没时间玩的博德之门 3 。也有很多新出的游戏如动物井、太空医院、哈迪斯 2 、Laysara: Summit Kingdom 、Final Facory 、Nexus 5X 、Sixty Four …… 很多很多。还回顾了以前玩过的老游戏。

我认为我应该做自己擅长的游戏类型。挑战新类型未尝不可,但可以慢慢来。把我这些年花时间最多(上千小时)的游戏列出来后,得到了三个选题:

第一、自动化工厂类型的游戏,还可以包括 基地建设 和 生存 这样的元素。

第二、有历史感的大战略游戏。记得 30 年前,我最想做的游戏是三国志。其实现在想起来依旧有趣。光荣的三国志系列固然好,但以现在的眼光来看,那些游戏玩点都太陈旧了。我更喜欢群星、维多利亚、十字军之王这些现代战略游戏。这些游戏都有三国题材的 mod ,都并不成功。真正成功的现代三国战略游戏,在我看来只有半个:全面战争:三国。

第三、传统意义上的 Rogue like 。我在 Rogue's Tale 上花了近千小时。Tome4 , Adom , netheck 也都玩过。我喜欢这些游戏玩法的内核,如果接入一些现代游戏的交互元素或许更好?比如博德之门 3 的玩法内核还是古典的 DnD ,但是交互设计上有了巨大的进步。Diablo 在我看来也是 Rogue Like 的进化,动作元素的加入让这个进化非常成功。

有好几天,我都在几个选题间犹豫,对于进一步的思考停滞不前。直到有天我把太空医院的战役通关,突然有了些新的想法。

其实,拘泥于做什么题目更好是没有意义的。任意一个都会很有趣。而做什么游戏也不必标新立异,比如太空医院,我玩的感受就很舒畅。要说和之前的双点医院到底有什么不同?粗看是差不多的。但当初双点医院刚出来时,因对牛蛙的主题医院的旧感情,我第一时间就买了。但当时玩了几个小时就犯困,而这次太空医院我就一口气玩了 20 多小时。为了防止一时偏见,我又重新安装回双点医院,发现感觉并没有错。引用我在 steam 评测中的一句话:“这么说吧,双点医院是旧时代游戏的高清画面重置版,而这个更有现代游戏的感觉。”

延续已有游戏类型其实并无太大问题,但因怀旧或是流行而复刻却没太大意义。加入现代游戏性元素也没问题,但不应硬去缝合。游戏玩点应该自洽,找到整个游戏每个环节应该有什么。从某个特定的乐趣点出发,在此基础上慢慢实现就好了。我突然有了创作欲。


核心乐趣:在不规则的有限空间中拼凑不规则板块。

我觉得这是一个非常打动我的玩点:在太空医院中摆放房间时感受到了这一点。玩异星工厂:太空探索 end game 修飞船时,我也曾反反复复的折腾最终飞船的设计图;warptorio 这个 mod 中也是需要在几个楼层的有限空间塞下整个工厂。

乐高、俄罗斯方块这两个是拥有亿万用户的积木游戏。我觉得拼积木板块可以获得某种原始的快感。对于游戏设计,这是一个很好的起点。然后我随意的完善了整个想法。有点杂乱无章,内容过多,不过无所谓,以后慢慢裁剪就好了。

在银河系中已有无数住人的行星,不同的星际种族。玩家扮演一个独立商贸船的舰长,带领一个船组在银河系中做生意,并探索终极秘密。

在游戏的一开始,玩家拥有一艘很小的飞船,只有寥寥几个舱室,和 2,3 名船组成员。在游戏过程中,每段航程从一个星球到另一个星球。每次抵达新的目的地,就可以做短暂的补给:招募新船员、扩展飞船、修改船上的房间设计。

每个舱室内的机器以半自动化形式工作,船员需要窜梭于狭窄的过道,操纵整个飞船。船的隔间设计,和内部机器的摆放及连接关系决定了船的运作效能。异星工厂和缺氧这些游戏确立了相关玩法是成立的,但这里还需要更多的设计工作。

对于每段航程,将是一个小型挑战,玩家要面对:

  1. 规划燃料、氧气、食物等消耗。
  2. 一路上有随机陨石飞来。可以通过:护盾、无人机等防御抵御。(它可能是一个小型塔防游戏)
  3. 有许多随机负面事件,电离风暴,恒星耀斑等等。这会考验船的供能情况,动态修理能力。
  4. 还会有海盗等敌人出现:需要使用舰载武器攻击,或跃迁逃逸能力。(这里,还可以考虑登船战斗)

这会是一个由随机性驱动的游戏,而不是设计好的若干战役:

船员的获得,初始能力点是随机的。人员的成长会决定:操作机器的熟练度、可以使用的高级机器种类。每段航程面临的挑战有随机性。游戏里的船组成员会永久死亡,游戏会因为全部成员的死亡而失败。游戏不会提供随时 Load/Save 的特性,所以每次启程需要考虑后备方案和额外的资源,避免中途失败。

但在每局游戏中,达成特定目标可以解锁遗产,遗产则能永久保留到未来的游戏中。


这个题材可以追溯到 银河飞将:劫掠者 ,后续还有自由枪骑兵和星际公民都有不错的口碑。这类游戏,我甚至玩过一个 rogue like 叫做 asciisector 。当然它们都不以设计飞船为重点。

在上面这个点子的产生过程中,有很多有趣的(我通关过的)游戏给了我启发。包括并不限于:

Ant 的资源内存管理

作者 云风
2024年6月4日 15:53

这两天着手做游戏 demo 时发现 Ant 的 Asset 管理模块之前还留有一些工作没有完成。

那就是,当游戏程序加载 Asset 后,资源管理模块何时释放它们的问题。在 ant.asset 模块中,我们为每种 asset (以文件后缀名区分)定义了 loader unloader reloader 三个接口,分别处理加载、卸载、重载的工作。

但在实际实现时,几乎都没有实现 unloader 。当时是偷懒,因为我们之前的游戏即使把全部资源都加载到内存,也没多少数据,并不需要动态卸载释放内存。而即使实现了 unloader ,管理器也没有实现很好的策略去调用它。只能靠用户主动调用卸载 api 。事实上,一个个资源文件主动卸载也不实用。

考虑到占用内存最大的 asset 是贴图,我们又对贴图做了一些特殊处理:

所有的贴图都可以用一张空白贴图作为替代。引擎有权在任何时候(通常是内存不足时)主动释放长期未使用的贴图,并换用替代。这个特性也可以很好的适配异步加载过程。

所以,未释放的贴图并不会撑满内存。

今天在查看引擎的预制形状相关的 API 实现时想起,我们对一些预制的模型,又有一些特殊处理。

预制模型,例如平板、箭头、方块等,通常在调试或写一些简单 demo 时使用。它们不是从 asset 文件中加载而来的,而是通过一些代码直接填写顶点数据创建出来。所以,这样的网格数据(mesh)并不在 entity 间共享,而是每个 entity 独有一份。目前其生命期跟随 entity ,即在 entity 销毁时主动销毁 mesh 相关数据。

所以,我们就在相关数据结构上打了个标记。拥有这个标记的数据,会在 entity 销毁时做销毁处理,以免造成资源泄露。

我觉得这个设计有不好的味道,所以这次想把资源管理模块重新做一下,统一文件加载的 asset 和程序化生成的数据的管理。

回到前面的问题:到底应该什么时候清理内存中的 Asset 数据?提供怎样的 API 清理?我认为是这样的:

  1. 规模较小的游戏或 demo 事实上并不需要在运行时清理,程序退出时一并释放干净即可。
  2. 规模较大的游戏,通常在场景切换时做清理,且不应让清理粒度太小,那样会增加很多的开发难度。

清理问题的难点在哪?

如果一个对象引用了某个 Asset 数据,通常我们不能直接清理它(除非是贴图管理那样的特例,偷偷替换实际的数据)。引擎中很难找到所有对象和 Asset 数据的引用关系,因为维护这样一张表有额外的复杂度成本。

但是,所有 Asset 数据目前都必定是引擎 ECS 中的 entity 引用,固然可以通过遍历 entity 的特定 component 找到引用关系,但整个 ECS 的 world 被销毁或重启时是一个更好的时机。因为这时,所有 entity 都被销毁了。

在切换场景时,我们建议重新创建 world 中的所有 entity ,这样,资源管理模块就可以把所有的 Asset 全部清理干净了。当然,实际实现时,我们不必真的释放所有的数据。做一个 cache 更好,如果同样的 asset 不久之后又加重新加载,就可以直接利用 cache 中的数据了。

顺着这个思路,我打算重构 Ant 中的 Asset 管理模块。先从 mesh 的管理改起,如果没有问题,就可以推广到所有的 Asset 。

  1. mesh 的 handle 只创建, 不单独销毁。不再区分从文件里读进来的 mesh 和程序创建的数据。
  2. 提供一个方法把内存中所有的 mesh 全部销毁。调用者会保证它不被引用。我们放在 world 的重建过程中,就能保证这点。
  3. 用一个 ltask 服务管理所有的 mesh , 它会做一些 cache 工作。 当 2 发生时不会立刻销毁数据,而根据 cache 算法来决定何时清理。

关于程序创建的 mesh ,我想新增一组 api ,在创建 mesh 时,同时用字符串命名,这样就可以和文件加载的 mesh 统一管理。如果程序创建 mesh 时有参数,将参数也编码到这个字符串中,保证字符串和数据有唯一对应。

Cache 我想使用一个简单的算法:

  1. 将数据分为 new 和 old 两个区,new 表示当前必须持有的数据,old 表示还在内存但可以被清理的数据。
  2. 在刷新时,将 cache 容量设置为 old/2 + new * 2 。然后把当前内存中所有数据置为 old 。
  3. 加载数据时:如数据已在内存中,把数据标记为 new ;否则,加载数据,并检查 new 上限,若超出上限,删除任意一个 old 数据。

重新启程

作者 云风
2024年5月6日 09:51

今天,是我在广州阿里中心办公的最后一天。虽然 Last day 设定在了 5 月 20 日,但后面全部是假期,应该不会再回来这里。这些年的年假我都没有用过,总是到年底自动作废,今年算是休全(一小半)了。

回顾我的职业生涯,2001 年之前在北京经历了创业,而后又短暂的工作了数月。之后便在网易工作了十年直到 2011 年离开 。2011 年底,我们创办了简悦,原本以为会把这家公司一直开下去,但在各种机缘下,于 2017 年底被阿里巴巴收购。之后,我便退出公司的管理,专心开发游戏引擎

每段经历,印证着不同的心境。幸运的是,每次开始和结束,都是我的自主选择。感谢那些容忍着我的任性的伙伴,而我执着于自己想法的同时,也回报了周遭的人。

毕业的开始缘于大学时代交的诸多热爱游戏的朋友,年轻气盛的孩子们梦想做出自己的游戏。当我觉得自己能力还不足时,不愿意只是为了工资而把精力消耗在不太喜欢的事情上,所以我选择离开,独自提高自己。

进入网易,因为我从大学期间就在开发的风魂引擎。阴差阳错,我又挽救了一个失败的游戏项目(大话西游),然后一干十年。而离开,是为了重新开始。当我发现我无法在网易做出更多想做的东西时,能自己去主导做事,是莫大的诱惑。

创办简悦轻松自然,虽说是创业,但几乎没有感觉到压力。这应该是整个团队经历过成功带来的自信吧。虽然前两年并不成功,接连两个项目都做失败了,但又奇迹般搭上了手机游戏的快车活了过来。

我觉得这次创业经历给我最大的启发是:团队合作创业,一定要先确定好股权及利益分配的方案,再全权交给可以信任的人去执行,大家就能没有后顾之忧的做事。直到和阿里谈公司收购时,我们的创始人团队依然遵循着最初的约定,让几乎所有坚持到最后的伙伴都获得了还不错的经济回报。而阿里也因为我们的加入,在短短一年内就收获 了数倍的投资回报,双赢的结局。

2018 年开始,我决定安心做一点想做而擅长的事。人生短暂,学习如何管理很多人做事并非我期望的发展方向。尤其当我逐步融入开源社区后,我发现,这个世界上许多软件基础设施往往都是由一两个人支撑。早在 2011 年时,我就怀疑过,软件项目需要很多人一起完成可能是一个骗局 ,那么,当处于一个稳定的环境而自己又有能力时,这种机遇并不多见,就应该尝试做点什么。

游戏引擎是我多年来一直想做的事情。早在二十多年前的校园里,我开始写自己的游戏时,就发现必须先有一些完善的底层设施支持。我的兴趣点一直都是挑战一个个具体问题。游戏引擎就由一系列具体问题构成,每一点做好都不容易。这件事需要一步步来,一步步试错,一个个问题解决。

我们在简悦时,游戏服务器部分的底层设施 skynet 从一开始就是开源形式开发的 。它几乎是由我一个人维护了许多年,同时又从开发社区吸收了大量的贡献,让它成长为一个成功的项目,甚至应用在游戏之外的大量领域。

而游戏客户端部分,一直未能建立自己的基础设施。虽然在 2013 年,我们在转型开发手机游戏时,我写了 ejoy2d ,但那只是一个为了支撑当时陌陌争霸这个游戏快速定制的小玩意。待到现在用于三国志战略版的几年,结构设计上已经不堪重负,欠了很多技术债。

虽然自己创办的公司已经完全卖掉,但我对这份事业还颇有感情。我认为,如果一个游戏公司想长远发展,那么拥有自己的底层设施颇具战略意义。自研开发是个缓慢的过程,初期一定看不到回报,很可能失败,但一旦成功,收益巨大。但这件事必须由一个不求短期回报,有强烈自驱力的人去做。除了我自己,还能有谁?


今天我结束在阿里的工作,是因为公司关停了 Ant Engine 这个项目。我感觉公司不再想发展自己的游戏客户端引擎,至少是不赞同我对于 Ant Engine 的发展规划。当然,这些出于个人推断,仅代表我的个人观点。实际上,阿里游戏目前以三国志战略版为首的诸多游戏底层依然基于自己研发的代码,也没有迁移到 Unity 等商业引擎上的计划。它依然有一支不小的技术团队持续维护中。希望他们接下来能做得更好。

好在叮当年初离开阿里离开前做了最后一件事,帮助我这些年开发的 Ant Engine 开源。四月以来,在叮当离开后,我一直在公司内努力促进引擎的发展。因为我真心觉得这六年来,我和我的小团队在 Ant 引擎开发这件事上一步一个脚印走得很踏实。我可以给引擎打上 80 分,开发过程中固然犯了很多错误,但最终,我想克服的技术问题,都在我的能力范围内解决的很好。自研引擎做起来虽然不容易,但能够花上六年专心开发更是难得的机缘,今天本应是开始让它开花结果的时候了。

整个四月,我和公司各个项目组的人长谈,项目制作人、程序、策划、美术…… 。感觉几周时间说的话超过了过去一年。我把我的信念传达到了。通过我们制作的游戏,也展示了引擎的质量:基于 Lua 快速开发的同时保证了在手机平台上的高性能,当前世代的图形管线表现出的还算精致的画面。以及,我认为最重要一点:没有欠下技术债。

我想我说服了不少人,但可惜的是,当下的公司决策人最终放弃它。或许阿里游戏现在有更急迫的事情要做,无法再等到三五年后它带来的收益。经过这些沟通,离开也让我了无遗憾。我已经做完了我能做的所有事,而过去几年的工作也颇有所获,可说是问心无愧。


之后,虽然开发团队已无法专职开发引擎,但大家对它还是颇有感情。好在主体功能均已完成,引擎本身的设计就倾向于方便扩展,我的想法本身就是像 skynet 那样,只专注于维护最核心的部分。引擎已经开源,是不是专职开发也就不那么重要了,主要开发者都会在业余时间尽力维护。

而我,也将开启下一段旅程。初步的想法是制作一款 Windows 平台的独立游戏。虽然我们在过去的一年已经在做一款类似异星工厂的游戏,但那主要是为了展示引擎在手机平台上的质量,它的 gameplay 方面还需大量工作。我并不认为手机是个好的独立游戏平台,需要把重心放在 PC 上。引擎对 Windows 以及 Steam 的支持,会是我的短期工作。这些会随着我自己动手编写游戏代码,慢慢完善。

具体做怎样的独立游戏,还在酝酿中。引擎开发团队中唯一的美术也和我一起离开阿里,他是一个多面手,和我一样对独立游戏开发有极大的兴趣,在接下来的几个月,我们会一起构想。

大批量动画模型的优化

作者 云风
2024年5月3日 15:03

最近和公司一个开发团队探讨了一下他们正在开发的游戏中遇到的性能问题,看看应该如何优化。这个游戏的战斗场景想模仿亿万僵尸(They are billions)的场景。在亿万僵尸中,场景中描绘了上万的僵尸潮,但我们这个游戏,超过 500 个僵尸就遇到了性能问题。固然,手机的硬件性能比不上 PC ,但 500 这个数量级还是略低于预期。

对于游戏中大量类似的动画物体,肯定有方法可以优化。我们来看看渲染这些动画可行的优化方向:

常见的方式是把僵尸先预渲染成图片,而动画自然就是多个图片帧。对于亿万僵尸这个游戏来说,它本身就是基于 2D 渲染引擎的,这么做无可厚非。

如果引擎本身基于 3d 渲染管线,也可以以预渲染图片的方式去渲染它们,但图片是否比基于三角形的模型渲染有更好的性能,这个需要根据具体场景去分析。

当我们在运行时,将模型的三维顶点信息传递给 GPU ,让 GPU 做光栅化,通常可以获得两个方面的好处:

一,最终渲染出来的像素有更准确的光照信息,它是根据该像素在场景中的空间状态计算出来。而预渲染图片上的像素则是根据预渲染时的固定空间状态计算,所以不准确。

二,可以省掉顶点着色器的计算过程,对像素着色过程也简化为一个简单的复制(而不必计算光照)。

但是,渲染图片也未必一定有绝对的性能优势。这是因为,预渲染图片本质上是将渲染过程预处理,烘焙到贴图上。约等于用空间换时间的优化。图片本身相比顶点数据会消耗更大的带宽。当模型本身顶点不多,用到的贴图也不大时,这个区别会更加明显。通常模型顶点占用的带宽会比图片需要的带宽小一个数量级。当使用帧动画时,需要为每个动画帧渲染独立的图片,这个差别就更加明显。


再来看动画。通常,我们会用骨骼来描述动画。这其实也是一种数据压缩方式。一个模型的顶点可能有成千上万甚至更多。但我们只需要用几十个关键点来描述动画即可。同时,只需要建立唯一的蒙皮数据,把数千个顶点映射到几十个关键点(被称为骨骼)上,每个动画帧就不再需要重复几千顶点的空间状态。

更进一步,如果动画的时间很长,即使记录每一帧骨骼的状态也会多余。我们会记录一些关键帧(key frame)上的骨骼状态,再用插值的方式求得每个渲染帧的骨骼状态。数据就得到了进一步的压缩。

让我们回顾这个流程:

骨骼关键帧 (通过插值得到)每个渲染帧骨骼信息,(通过蒙皮按权重计算出)这帧模型每个顶点(经过光栅化)映射为屏幕上的画面。

这里的每个环节,前面的都有更少的数据,可以通过索引和计算,得到下一个环节更多的数据。因为 GPU 比较适合做简单但巨量的并行计算,所以,我们每个环节都可以考虑把提前预运算,或是实时计算;计算可以考虑放在 CPU 中,或是放在 GPU 中。每个选择必定有取舍,都可以根据实际场景考量。

当需要渲染的物件非常多时,为了提高性能,要么尽可能的把数据预预算好,而不要每帧都反复计算;要么把计算放入 GPU 中,让处理器可以尽可能的并行。

上面列出的动画流程中,中间一步“从蒙皮计算出顶点”是最有弹性的,根据不同的场合会有不同的处理方法。

早年 GPU 无法处理复杂的业务,大多数引擎会选择在 CPU 中处理蒙皮。如今,GPU 有了 compute shader ,把蒙皮放在 GPU 中处理越来越普遍。 但今天谈的这个场合,我任何并非最佳方案。

因为在 GPU 中计算蒙皮,也面临两个选择:其一,渲染每个物件,都从骨骼到顶点再到屏幕像素跑一遍完整的流程,这样带宽开销是最小的,都计算量最大;其二,把蒙皮过程分离出来,GPU 计算出蒙皮结果,然后把蒙皮后的顶点和其它静态模型顶点统一处理,这样做需要临时占用额外的显存来保存蒙皮计算结果。

如果选择方案一,我们会多出相当多的重复计算。首先,3d 管线中,每个模型都不只处理一次渲染。会有 preZ ,阴影等额外的渲染步骤,这个方案会重复计算蒙皮几次。其次,更重要的是,对于僵尸潮来说,僵尸的数量越多,在播放动画时,恰巧处于同一个动画帧的可能性就越大。当渲染数以千计的僵尸时,会做相当数量的重复蒙皮计算。

如果渲染方案二,我们会为每个僵尸对象的蒙皮结果在显存中保存一份临时顶点数据。PC 上或许有足够大的显存,但手机上非常容易显存不够。(我们过去尝试过这个方案,在手机上果真遇到了显存不足的问题)固然我们可以继续优化方案二,加一个间接层合并一些重复的数据,但渲染管线的复杂度会上升很多,暂时不往这个方向考虑。

实际上,把僵尸模型的所有动画的每一个动画帧的蒙皮结果预算计算好,在这个场景是最为简单有效的方案。

游戏中,单个僵尸只有不到 300 个顶点,以每秒 30 帧动画计,每秒动画不到 10k 顶点数据。预计算 100 秒动画数据,手机显存也能存放的下。我们只需要在 CPU 中烘培好这些数据,就可以把这些动画顶点变成静态模型一并处理。然后使用简单的 instancing 方法就能批量处理上千僵尸的渲染。

以上就是最近在 Ant Engine 中增加的特性,实测在 iPhone 12 上可以流畅渲染数千僵尸动画。不过,这个游戏本身不是 Ant Engine 制作的。虽然我和项目制作人讨论过用新引擎重新制作的可能性,因为开发计划的压力未能实施。


另外,我们在 Ant Engine 中实现这个新特性时,接口设计中用到了一个小技巧:

Ant Engine 是基于 Lua 的。Lua 的函数调用有不小的开销,所以我们在使用时应该避免每帧做过于频繁的调用。对于动画系统,底层接口需要设置每个模型每帧渲染使用的是预计算好的顶点组中的哪一段(以此为 instancing 的一个参数)。逻辑上讲,就是需要通知渲染层动画模型当前帧需要渲染的帧号。

当僵尸数以千计时,每一帧都需要修改每个僵尸的动画帧号,总共数千次。这每帧数千次的 Lua 函数调用其实是可用一个简单的技巧避免的。以走路动画为例,引擎内部其实只持有一个预渲染好的走路动画所有帧的对象。我们每帧都固定推进这个对象的 offset ,而使用这个动画的对象,它们实际保存的是一个起始相位值。在 C 层,每次渲染时都把这个相位数和动画对象中的 offset 相加再对总动画帧数取模,才得到真正的帧号。这样,我们只需要每帧去推进单个动画对象的 offset 就够了,而不需要上层通过 Lua 接口修改每个对象的动画帧。

死亡

作者 云风
2024年4月17日 10:17

早上上班的路上,在必经的一条马路,看到一个蓝衣人趴在路中间隔离带的水泥桩上。车流比较大,一晃就开过去了。我问坐在副驾驶的老婆:刚才那个人好奇怪啊。她说是啊,那个人怎么了?我说,昨天你没看到吗?他保持那个姿势已经一整天了,感觉连手指头都没动过。

说到这里,我赶紧把车靠边停下来打了 110 ,附近的派出所联系了我,说马上派人过去看看。我的心情久久不能平静。走在路上,我喜欢观察四周的细节,其实昨天就感觉不对了。路上没有血迹,我以为他/她(看不到脸)是在翻越隔离带时身体不舒服想休息一下。但一个人保持那种姿势几分钟而不动肯定不正常。如果昨天这个时候就报警或许更好呢?

如果下一次看到这样的情况,我想我会停车下来拍一拍,说不定就能救人一命了。

后面的路上,老婆和我讨论了死亡。

她说她很怕死,不愿意去想。她父亲是做警察的,曾经还做过侦察兵上过越南战场。小时候就见过父亲一身是伤回家,据说是骑车追罪犯摔了一跤。她当时非常害怕。我说,人终有一死,现在不愿意想,总有一天也会想的。

我有一个理念:年轻的时候,人人都怕死,这可能是刻在基因里的。我们都回避不了这种恐惧。但说不定人老了就不会那么怕了,在死前的十年,每个人都会慎重的思考死亡,想的多了,或许就释然了。就好像我们小时候去想长大后的人生,结果想法一天天都在变化,等真的长大了,发现想法完全变了。

死亡可能也是一样,想着想着到那一天,你就和死亡和解了。我上上一辈的亲人最后走的时候都很安详,他们都不害怕,感觉甚至在最后一刻有点期待。

希望我将来也能这样。

Ant Engine 的一些优化

作者 云风
2024年4月12日 13:08

最近一段时间都在公司内寻找项目可以合作推进 Ant Engine 的使用。我觉得自研引擎的一个重要优势在于我们可以针对具体游戏更好的做性能优化。在目标设备硬件性能允许的范畴内,把画面质量和交互体验做到更好。而同样的优化手段,在通用商业引擎上面做会困难的多,甚至无法顺利完成。

我们用 Ant Engine 制作的第一款游戏 Red Frontier 在一年前是性能完全不达标的。它在 iPhone 8 上甚至都达不到 30fps ,无法流畅游戏。很多性能问题是已知问题,比如我们用 Lua 搭建了整个引擎,一开始只考虑了引擎结构和正确性,把性能搁置在一边待后面再处理。

优化方案是一开始就想好的:借助 lua ecs 框架,把数据结构放在 C 内存中,必要时可以绕过 Lua 代码,直接用 C 代码控制核心数据。我们花了大约 3 个多月的时间将核心渲染系统用 C 重写后,就把性能提高了 1 个数量级以上。这个过程可以说是一直掌握在手中,按计划推进。

但即使可以让游戏运行在 60fps 下,优化的目标也远远没有达到。这是因为对于手机设备来说,用户更容易产生电量焦虑。在固定座位上插着电玩主机或 PC 游戏,玩家不会去想游戏机耗了多少电;即使把 switch 外带玩游戏,也可以一直玩到没电;但用手机不光是用来玩游戏的,如果消耗电量太快,玩家会担心手机等一下会不会无法支付交通费用,不能扫码吃饭……

我甚至一度怀疑,手机并不适合长时间沉浸式的游戏类型。或许放置游戏这类玩一下放一下的游戏类型更合适一些?

游戏达到要求帧率后,继续做性能优化和传统游戏引擎非常不同。

过去我们做性能优化,追求的是每项任务的低延迟。最快的做完每件事就能提高帧率。但节能不是这样的考虑,在较长尺度内,尽量少做重复运算,才能减少总的能量开销。能缓存住耗能较高的计算结果,哪怕复用这些结果比重新计算更慢,恐怕也是值得的。

其实手机的硬件设备就是在向这个方向努力。例如 Tile based rendering 的 GPU 架构,它本质上不是为了渲染更快,而是用能耗更低的手段检测出潜在的重复计算,缓存下渲染结构,争取在下一帧复用。最重要的目的就是节能。

软件设计上更是如此。我在之前的 blog 中就探讨过可能会做的一些优化方向


另一方面,我们需要针对具体的游戏去考虑优化方案。

比如我们在现在的游戏中使用了这样一些优化手段:

虽然场景中有大量的对象时刻在运动,且这些运动和 gameplay 息息相关。例如在公路上跑的小货车、仓库和机器间来回运转的无人机。如果用常规方案,它们均需要每一帧都重新计算位置。如果数百个这样的对象都在 Lua 中计算位置,性能就会受到冲击。不光是位置计算,还涉及把状态同步到渲染底层。

我们实际在 gameplay 层用了一个非常低的帧率计算这些对象(小车/无人机)的位置。然后在引擎中实现了一个运动插值器,可以直接在 C 中补足运动轨迹,把 gameplay 中计算出的离散点变成连续轨迹。这样就大大减少了在 Lua 中的计算量。

另一个例子是动画。

游戏中大量的建筑都带有丰富的动画。一开始,我们在 blender 中制作建筑的细节动画,给建筑做上蒙皮、骨骼,再导出到游戏中。一般来说,受硬件限制,即使有大量相同建筑,但因为其动画轨迹上的相位不同,我们也很难把这些带有蒙皮的建筑事先都提交到显存中用单一绘图指令批量绘制,即所谓的合批操作。

但我们在这个游戏中还是做了一些尝试。例如,我们把建筑的动画分为四类,停工动画、正常运转动画、以及两个状态之间的衔接动画。然后,把这四组动画均做成相同时长三秒。gameplay 层在需要渲染时,都在 3 秒间隔上切换动画。这样,就让所有相同建筑播放同一个动画时,动画轨迹的相位是完全相同的。

在此基础上,我们就可以让引擎把相同建筑带上蒙皮合并渲染了。

前几个月,我发现,我们游戏中用到的动画全部都是一些机械运动。看起来使用蒙皮动画并不是必须的。只要我们可以把机器都拆解成一个个机械零件,每个零件其实都是刚体,骨骼只需要直接驱动这些刚体,而不必借助蒙皮去驱动网格上的点集(蒙皮)。如果把动画改成这种形式描述,整个游戏完全不需要蒙皮动画,而机器的那些相同的零部件天然就可以合并成同一绘图指令。

要做到这一点,只需要做好美术用的工具,让美术直接用定制的工具来制作机器动画就可以了。我们花了 3 个月的时间,项目组内唯一的一个美术就完成了将原有的蒙皮动画全部转换为自有形式的刚体动画的工作。游戏性能也就大大提高了。

目前,我们游戏在 iPhone 上已经不太耗电,正常游戏过程,相同时间甚至比玩微信刷抖音还要省电,所以我对游戏性能方面的表现也就没那么焦虑了。接下来可以有更多时间去考虑 gameplay 方面的调整。


前几天,我看了公司另外一个刚开始的新项目。是一个沙盒生存类的 3D 游戏。第一眼看上去场景有点像塞尔达,又或者说有点魔兽世界场景的感觉。场景特别的复杂。目前是用 Unity 制作的,虽然最终会在手机上运行,但现阶段在 PC 上也不到 30 fps 。简单看了一下,场景中 gameobject 就多达十万个之量级。CPU 有非常大的压力。

虽然该项目短期不太可能更换引擎。但我还是忍不住去想了一下,如果我们用 Ant Engine 参与制作的话,应该怎样优化如此之大的场景。

Ant Engine 目前已经很好的处理了:“内存中的对象数量对当前帧需要渲染的对象数量无太大影响”这个问题。即使内存中有 100 万个对象,但如果有方法确定只渲染其中 1000 个对象的话,100 万数量级不会对性能造成冲击。关键问题在于怎样从待渲染集中剔除掉大量不需要的对象。

针对魔兽世界这种视角的 3d 游戏场景,一个最大的特点是,摄像头跟随着玩家,它永远以相对场景规模来说较慢的速度运动。也就是说,一旦我们以人物为中心,剔除掉大量不需要渲染的对象,这个剔除过程并不需要非常频繁的操作,而只需要及其低频的修正剔除结果就够了。

游戏场景虽然复杂,但却是基本固定的。所以,我们可以做相当多的离线标注。例如,在房间中,如果没有窗户,就不会关心房间之外的世界;反之,如果在户外,即使房子近在眼前,也不用关心室内的家具。

用距离和物件的大小两者就能剔除大量的物件,而不需要特别复杂的空间分割方案。物件越小,可见距离就越短。

剔除过程其实可以放在独立线程中,根据离线标注做一个非常粗略的筛选。筛选结果比设计可见性要宽泛一点。这样,即使筛选结果有一点的误差,也不影响正确性。我们在渲染系统中,再对这个粗筛可见集再做一次视锥体剔除。这个剔除过程完全可以做成异步请求,即根据玩家的位置等参数,请求一个粗略的可见集。

这个计算过程可以比较复杂,利用来离线标注的信息。因为它非常独立,所以这些复杂性不会增加系统整体的复杂度。而结果允许晚几帧甚至晚几秒才到达渲染线程。这样做的正确性是由“玩家在场景中移动速度相较场景规模非常慢” 这个预设前提来保证的。如果 gameplay 要求玩家在场景中做瞬间大范围移动,那么也可以简单的把异步请求改为同步请求。

同样,如果我们在场景中需要摆放很多的点光源来增强表现力的话,也不应该完全把光源剔除的工作完全做在渲染层。使用一个异步光源剔除方案且结合游戏场景特点定制(使用更多的离线标准信息)会更为廉价。

重构 ltask 的任务调度器

作者 云风
2024年3月22日 10:30

ltask 是 Ant engine 的基础设施之一,在对 Ant engine profile 的过程中,我们发现了 ltask 的一些值得提升的地方。

我们希望尽可能的提升游戏帧率,缩短渲染每一帧的的时间。因为 Ant engine 是由很多并行任务构成的,任务调度器的策略会直接影响单帧需要的时间。

ltask 虽然和 skynet 想解决的问题是一样的:管理 m 个线程(任务/服务),让它们运行在 n 个 cpu 核心上。而它们的应用场景不同,ltask 目前用在游戏客户端,它由一两个重负荷任务和若干低负荷任务构成,优化目标是低延迟;而 skynet 主要用在服务器上,由数以千计的类似负荷的任务构成,优化目标是高负载。

低延迟和高负载在某些方面是对立的。

对于服务器,为了提高负载能力,应该最简化调度器,最好感觉不到调度本身,并充分利用所有的 CPU 核心。最简单的办法是,为每个 CPU 核心准备一个任务队列,把任务公平的分摊给它们。在任何一个核心快要闲置的时候,就从起他核心的任务队列上匀过一些任务,让它们都不要闲置。也可以用一个大的任务队列,让每个核心都从这里取任务,同时设计一个好的算法减少多个核心取任务时对这个大任务队列的竞争。

对于客户端,如果想降低绝对帧时间,需要做的是让主线任务有最低的延迟:每个步骤在它所依赖的任务准备好后,可以最快时间开始运行。尽量不要耽误主线任务的运作,因为它的执行时长往往决定了一帧所有任务的运行时长。这就需要更复杂的调度器算法,和更多的调度器本身的运行开销。用这些额外开销(以及复杂度)换取更低的延迟。


调度算法会影响主任务的延迟增加,并不是最近才遇到。去年这篇 blog ,就列举了当时碰到的一种情况

当时用了一个并不彻底的解决方案,最近又想重构这块代码,就重新思考了这个问题。

之前 ltask 和 skynet 不同,区分了两种不同的服务:一种是共享工作线程的,由调度器调度分配时间片;另一种独占系统线程,由操作系统调度。我们把不太好处理的任务都交给了独占线程,期望操作系统可以比我们做的更好。

skynet 里也有独占线程,只不过没有开放接口给开发者使用。它们是 timer 线程,网络线程,以及一个监控线程。ltask 把这个机制分离出来让开发者也可以实现特别的独占线程的服务。这样,像 IO 处理就不必放在 ltask 内部,这样,ltask 可以更纯粹:只需要把多虚拟机多线程这一件事做好即可。

一开始,我们期望独占线程服务尽量简单,它只解决普通服务无法做到的事,而不去做复杂业务。所以,最早的 api 是分成两套,分别针对两种不同的业务。但随着开发进程,我们越来越分不清期间的界限,最终还是希望两者可以统一起来。但这就让中间层变得更复杂了。

如果我们仔细考虑,独占线程服务要解决的问题,其实有三个方面:

  1. 服务调用了会阻塞的系统 API 。例如,timer 服务就是依靠系统 sleep 来控制时间节奏的;IO 服务会阻塞在 select (或 epoll wait )上。这些 api 会一直占据着系统线程,让这个线程在等待系统调用返回前无法由调度器分配其它任务。

  2. 有些 C 库的 API 只允许在同一个线程调用(可能它内部使用了 TLS ),所以光保证一个服务的任务串行不够的。工作线程可能把同一个服务的任务调度到不同线程上。部分 C API 还要求必须在主线程调用。

  3. 像 iOS 这样的系统,把窗口创建完毕后,线程会阻塞在最后一个系统调用上不会返回,直到系统退出。

关于第一点,我在上一篇 blog 就提到过解决方案。值得一提的是,python 的多线程库就是使用的类似的理念:虚拟机和 C/C++ 代码运行的部分是可以分开看待的。这就是为什么,python 虽然有 GIL (全局解释器锁),多线程依然有意义。python 靠 GIL 把虚拟机解释器和所调用的 C API 隔离开了。一旦进入 C 库,就离开了 python 解释器,这时的 C 调用就可以并行运行了(C 代码自己保证线程安全)。GIL 保证了所有 python 解释器运行的 python 代码都是串行的。

一开始,我按这个想法为 ltask 实现了一套 api 。但感觉使用者较难用对。这是因为,我不想把 lua vm 的 lock 打开(相当于 python 的 GIL );如果不打开 VM 的锁,即不靠锁界定 VM 和 C 代码的边界,那么就需要在封装阻塞系统调用时主动指定边界,也就是上一篇 blog 谈到的,提供 yield 和 resume 两个 C api 。不过,这样就需要改写我们已经实现好的 C 库。

就我自己对 Lua VM 的了解,其实 Lua 本身也一定程度上的允许并发,但必须小心对待。它需要:使用独立 coroutine 、不触发 gc 、不构造新对象…… 在这些限制条件下把以上的 C API 再封装一个 Lua API 也不无不可,但使用者一旦滥用,bug 将难以定位。

最后,我放弃了这个方案。回头再来看第一个问题,其实阻塞住工作线程并非不能接受的事。我们可以增加工作线程数量(超过 CPU 核心数量)来缓解处理能力下降的问题。同时写一个更好的调度器算法,针对这种会长时间挂起的工作线程做优化。

第二个问题才是 ltask 一开始未能很好解决的:如何让一个服务永远由同一个系统线程运行。

为此,我重构了 ltask 的调度器,支持了这一特性:将一个服务绑定在特定工作线程上。值得一提的是:虽然大多数游戏引擎都把主要任务固定在一个系统线程上,比如区分渲染线程、物理线程等等。但把这些交给系统调度器并不是最优解。绑定线程(或绑定物理核)不等于低延迟。

让我们做这样一个思想实验:如果一个开发团队面临很多琐碎的开发任务要处理。如果团队中每个人(工作线程)都是多面手,可以处理一切任务。那么,最简单的管理方法就是,每个人闲下来就去找一个可以立刻开始的任务做,做完后再周而复始。

如果,某一开发任务非常重要,它直接影响整体的开发进度,又只有一个开发人员有能力处理的话,应该怎么做呢?

管理者会倾向于把这个任务的所有步骤都绑定在这个特定开发者身上。但是,如果任务做到一个阶段,等着另一个任务完成怎么办?通常不会它闲等着,总得去做点别的什么吧。一旦中间开始了别的事情,就无法立刻放下,待到之前等着的依赖项完成后,也无法立刻接着干了。

从这里可以看到,把任务绑定在某个特定人身上未必是降低延迟的最优解。最好还有另一个可以做这件事的人可以当替补。如果没有这样的替补,绑定特定的工作者要么拖长了最终完成的时间,要么浪费了干这个事的人的产能(让其不能开始工作时空等)。

针对具体的情况做具体的调优需要对要解决的问题有足够的了解。这是自己写任务调度器比系统任务调度有优势的地方。

对于第三个问题,我们最终不打算由 ltask 中解决。因为它是一个唯一的特例,暂时只在 ios 环境上遇到一例。而且 ltask 是一个库,而不是一个框架,所以依旧可以单独为它写一些代码,把这个特殊任务的特殊流程放在 ltask 之外即可。


对于解决以上几个问题,看起来独占线程服务并不是必须的。只需要为共享服务增加绑定(但不是独占)工作线程的能力即可。

昨天,我们完成了重构最后的工作。新版本的 ltask 任务调度器在游戏上工作良好。从 profile 结果看,达到了我们的优化目标。

当然,最开心的是,我可以删掉独占线程服务这个大块的特性。围绕这个特性的诸多支持代码也可以简化。最终,光 C 代码就去掉了 600 多行,Lua 代码也被大量的简化。

以非阻塞方式执行一个函数

作者 云风
2024年3月1日 18:46

在 skynet 中,服务之间并行运行,而每个服务自身的业务都是串行的。一个服务由开发者自行切分成多个时间片,每个时间片串行运行在不同的工作线程上。最常见的做法是在每个服务中运行一个 Lua 虚拟机,用 coroutine 切分时间片,这样从编写代码的角度看,任务是连续的。

这个设计的目的是,让开发者轻松享受到多核带来的并发性能优势,同时减轻编写多线程程序带来的心智负担。

用过 skynet 的应该都碰到过:当我们在服务中不小心调用了一个长时间运行而不返回的 C 函数,会独占一个工作线程。同时,这个被阻塞的服务也无法处理新的消息。一旦这种情况发生,看似是无解的。我们通常认为,是设计问题导致了这种情况发生。skynet 的框架在监测到这种情况发生时,会输出 maybe in an endless loop

如果是 Lua 函数产生的死循环,可以通过发送 signal 打断正在运行运行的 Lua 虚拟机,但如果是陷入 C 函数中,只能事后追查 bug 了。

那么,如果我原本就预期一段 C 代码会运行很长时间,有没有可能从底层支持以非阻塞方式运行这段代码呢?即,在这段代码运行期间,该服务还可以接收并处理新的消息?

在很长时间里,我认为在保证前面说的严格约束条件下,无法实现这个特性。这个约束就是,skynet 的服务必须以串行方式运行。但最近,我发现其实用一点巧妙的方式,还是有可能做到的。但我们需要重新审视约束条件。

我们约束了 skynet 的单个服务以串行方式运行,指的是,所有对服务 context 的操作都是串行的。如果是一个 Lua 服务,这个 context 应包括 Lua VM 。但是,如果一个需要长期运行的 C 函数并不需要访问 context (包括 Lua 虚拟机),而实现者自己能保证函数自身没有竞态问题,那么,在运行这段代码的同时,让另一个工作线程继续处理同一个服务,其实是满足条件的。

假设让 skynet 提供两个函数:skynet_yield()skynet_resume()

当我们调用 yield 时,通知框架结束当前服务的时间片。这时,该服务的工作线程阻塞在服务的回调函数上,但我们依然可以关闭时间片。同时,框架可以额外启动一个备用的线程,补充临时减少的处理能力。这个服务被放回调度队列中,运行其它工作线程处理它的后续消息(即,可以继续调用服务的处理函数)。

等长期任务执行完毕,它并没有离开同一个工作线程的同一个 C 调用栈,但这时调用 resume ,框架则去检查当前服务是否正在被其它服务处理。如果有,等其它处理线程处理完毕后,不要归还服务进调度队列,由 resume 调用者继续后续的流程。

这样,我们通过 yield/resume api 拥有了在不离开当前工作线程而临时切分时间片的能力。只要实现者自己保障 yield 和 resume 之间的线程安全问题就够了。


从 Lua 的角度看,如果预期一个 C 函数调用可能是长期的,那么就在这个 C 函数中加入 yield 和 resume ,隔开耗时的部分,并保证被隔出的部分不会访问 Lua 虚拟机即可。

对这种特制过的 C 函数的调用,使用上看起来就是远程调用了一个系统服务,让出了 Lua 虚拟机并等待回应。但这个系统服务实际上是在当前的 C 调用栈上执行的。

这种略显诡异的方法,其实我在 ltask 中就实现过

接下来如果我给 skynet 增加这个特性,看起来可以做到之前难以完成的任务。

比如说,网络线程其实可以实现为一个常规服务,而不必像现在这样放在 skynet 的内核中。目前这样做是因为网络处理部分会阻塞在 epoll 的 wait api 上。当等待新的网络消息期间,它无法正常处理 skynet 的内部消息。

一旦有这样的特性,我们只需要把 wait 夹在 yield 和 resume 之间就可以了。

封装一些现成的自带阻塞 api 的 C 库也会更容易:我们可以直接接入官方的 db driver ,而不必把它们的 io 部分换成 skynet 的专有 api 。

关于虚拟文件系统的一些新想法

作者 云风
2024年2月22日 15:21

虚拟文件系统 (vfs) 是 Ant 引擎的核心模块。在 wiki 上有介绍blog 上也有总结

最近在按前段时间拟定的思路重构编辑器。在这个过程中对 vfs 有了一些新想法。短期内不打算把工作重心放到重构 vfs 上面,先记录一下。

最早设计 vfs 的时候,是从网络文件系统的角度看待它的。我把它设想为一个类似 git 的组织方式,带版本控制的网络文件系统。所以,很多设计思路都是延续这个而来。但是,经过了这些年的数次重构,我对最初的思路产生了一些怀疑。

其中,最重要的一条:在游戏运行时,游戏程序看到的 vfs 是一个树结构的不变快照。这样,它像 git 一样,就可以用一个 Merkle tree 的 hash 值就可以代表这个快照,也可以方便的通过网络同步它。

为了实现编辑器,我们在这个设计上打了一些补丁,让编辑器可以在运行时动态的修改它。而我今天反思,“不变快照” 这一点是否是多余的?或者并不需要这个约束,也可以用简单的方案实现现在所有的功能。

经过一些思考后,我认为可以用这样一个新方案:

  1. vfs 是一个在运行时可以动态增删的树结构,而这个树结构只存在于内存中,不需要持久化。一开始,vfs 只有一个空的根目录。

  2. 在运行时,需要不断的对 vfs 进行增改。把空的根目录扩展出更多的文件。我们可以设置一个路径 path 为一个特定的 hash 值。如果这个 path 中的任意一个子目录不存在,都应该被立刻创建出来。如果 path 对应的文件已经存在,则覆盖掉原有的值。即:vfs 是一颗树,每个叶节点都是一个 hash 值。

  3. 可以把 vfs 树中任意一个 hash 值绑定内容。这个内容可以是内存数据块,也可以是一个本地文件(加上时间戳)。hash 值是数据内容的 sha1 ,所以同一个 hash 值只可以(也只需要)绑定一次。

  4. 另有一个模块,可以递归计算本地文件目录:这包括所有文件及子目录的 hash 。生成一张表。如果程序在本地启动,不连接文件服务器,那么在启动时,计算本地目录的 hash ,用 3 中提到的 api 把相关 hash 全部绑定,再将 vfs 的根替换为计算好的 root hash 即可。

  5. 如果连接文件服务器,那么可以向文件服务器请求当前任意 path 的 hash ,以及任意 hash 对应的内容。并在本地 cache hash 的内容。这个环节和现在已有的实现完全相同。

遵循以上的方案,vfs 就不必遵循不变快照的假设。而是在运行时可以任意增删查改。和现有的方案相比:vfs 模块变成了管理一个纯内存数据结构,而不涉及网络同步,和外部数据如何存放于本地文件系统中无关。

为 log 实现的无锁 Ringbuffer

作者 云风
2024年2月5日 14:17

这两天在改 log 模块。我们需要一个并发写 log 的模块,它有多个 log 生产者一个消费者,这个唯一的消费者在 log 线程中把 log 数据持久化。

大多数 log 生产者是在第三方库的 callback 函数中调用的,比如 bgfx ,如果写 log 不够快的话,就会阻塞渲染。这个 callback 需要自己保证线程安全。因为 bgfx 支持多线程渲染,所以写 log 的 callback 可能在不同的线程触发。

过去在实现 bgfx 的 luabinding 时,我实现了一个简单的 mpsc 队列,get_log 这个函数就是那个单一消费者,它取出队列中所有的 log 信息,返回到 lua 虚拟机中。

它是用 spin_lock 实现的。这两天,我想应该可以实现一个更通用的无锁版本。

在我的需求中,log 信息是允许丢掉的。所以我开了一个固定大小的 ringbuffer 收集各个不同线程生产出来的 log ,然后在一个单一线程定期(通常是一个渲染帧一次)取出它们。只要取的频率够高,而生产的 log 数量不那么快的话,一个合适大小的 ringbuffer 就能以最简单的数据结构解决问题。

我觉得一个无锁结构的 log 系统需要两个 ringbuffer 。

我们缓存的 log 条目数目上限估计不用太大,4096 或许是个合适的数字:即,每帧不会产生超过 4000 条 log 。那么就用一个 4096 的固定数组即可。

实现这么一个 ringbuffer 需要有两个 64bit 变量,head 和 tail 。其中 tail 被多个生产者共享,所以它必须是原子变量,让多个生产者依次尾进头出这个队列 ring buffer。head 只由唯一消费者控制,不需要原子变量。写入数据保持这样的流程:

  1. index = fetch and add tail, 1
  2. buffer[index % 4096] = meta

这里只需要记录 meta 信息,而不是 log 的文本。这里的 meta 信息只这一条 log 的实际内容在另一个 ringbuffer 中的 offset 和 size 。写入 meta 信息时,需要先写 offset 再写 size。为什么是这个次序,下面会展开说。

第二个 ringbuffer 记录 log 的文本内容,可以用一个更大的队列,比如 64K 。这个 ringbuffer 只需要一个 64bit 的原子变量 ptr 。而将 log 文本写入 buffer 只需要下列的流程:

  1. offset = fetch and add ptr, size
  2. copy string to buffer + offset % 64K (回绕时,需要分两段复制)

也就是说,我们把 log 文本写入一个固定长度的 ringbuffer 时,只要不断的推进 ptr 指针,然后写入数据即可,不用考虑是否覆盖了旧数据。

而 log 的消费者负责检查数据是否还在 ringbuffer 中,或是已经被覆盖丢失。这个检查条件非常简单: offset + 64K 小于 ptr 表示该 offset 处的内容已经不在内存中。因为持有引用方记住的 offset 和 ringbuffer 自己的 ptr 都是 64bit 单调递增的,而内存中只保存有 ptr 之前 64k 的内容,比较它们两个值就能知道数据是否有效。

在第一个 ringbuffer 每个条目的 meta 信息中,我们保存有数据在第二个 buffer 中的 offset 和 size 。读取后便可以校验读到的数据是否有效。

唯一一个读取 log 的消费者可遵循这样的流程:

  1. 如果在第一个 ringbuffer 中, head == tail 表示队列为空。
  2. 如果 head 对应的 meta.size 为负数, 表示数据还没有准备好(也可以视为空)。
  3. 队列有效时,index = head++ 。递增 head 。
  4. 根据 buffer[index] 的 offset 和 size 从第二个 ringbuffer 中取出内容。
  5. buffer[index].size = -1 。赋值 size 为 -1 。这样可以标识这个 slot 是无效的。这可以保证在生产者填入数据时(最后写 size 字段),如果没填完,以上 step 2 就能检查出来。

我简单实现了一下:

https://gist.github.com/cloudwu/e8cc734a31dd01b439d8d131acc361c3

尚未测试。而且就我写并发代码,尤其是无锁结构,是很容易出错的。所以以上代码仅供参考。它的确很简单,如果有 bug 也应该很快能发现。

一个格式化文本信息版面的小玩意

作者 云风
2024年2月4日 14:41

bgfx 提供了一组调试文本输出的 api ,可以把一些文本信息显示在屏幕上。这些 API 非常简陋,只是提供了一个文本模式缓冲区。离控制台还很远。

具体见 文档中 的 dbgText* 系列函数。

随着我们的游戏引擎中越来越多的信息需要展示,直接使用这些 api 就越发简陋了。最近萌发的想法是干脆使用 imgui 来绘制调试信息界面。但我又觉得保留 bgfx 自带的这个文本模式也有一些好处。

这个周末,孩子被爷爷奶奶带回老家去了。难得有不需要陪娃的一天。我周六一大早起来就在想写点什么。

最开始的想法是,使用基于 ncursors 的 UI 库。翻了一下没看见什么特别喜欢的。而且 bgfx 的文本模式并不是一个终端,可能还需要先把它改造成一个 VT100 终端先。

在 github 上搜索了一番,我用 VT100 Emulator 找到一些简单的库,没见到开箱即用的。感觉自己实现一个 VT100 终端也不算太复杂。大约 400 行代码就够了。主要是实现 ansi escape code ,有了这个,就能对接 ncursors 或 pdcursors 之类的库,然后文本界面 TUI 的选择也有很多。

然后,我发现了 imtui 这样一个有趣的玩具。它给 imgui 加了一个文本模式的 backend 。看起来还是挺炫酷的。但仔细一看,项目不太活跃很久没更新了。翻了下实现,也就几百行代码,花了半个小时就懂了。

imgui 输出的是 draw list ,即绘图指令列表。backend 把这些绘图指令传给真正的图形 api 画出来就好了。但是,这些绘图指令包含的是顶点数据流,而丢失了最初想画什么这个信息。

比如,UI 上的文字,在 draw list 里看到的就是两个三角形;菜单上的箭头,变成了一个三角形;圆形则变成了很多很多三角形……

如果你想在文本模式下重现这些图案,在不修改 imgui 的代码的前提下,只能靠猜。猜 draw list 里那些三角形到底在干什么。然后把 draw list 的顶点流切分开,还原成更高阶的绘图指令。imtui 这个库在这方面做的并不算太好,我一下就想到了许多猜测的方案,要廉价很多,更好实现。

花了一个上午,我模仿 imtui 自己写了一个新的 imgui 的 backend ,我是这样猜测的:

我为字体定义了一个特有的 texture id ,如果 draw list 里用到这个 id ,就一定是在绘制文字。然后,我使用自定义字形,把 ascii 码都从 imgui 的默认字体中替换掉,换成一个个 1x1 一个像素的字模,并把 ascii 写到贴图上。这样,在 drawlist 里发现文字绘制的时候,我直接根据 uv 取字体贴图,就能取到文本的 ascii 码。

然后,drawlist 里相邻两个三角形如果能构成一个矩形的话,就认为是背景框。在文本缓冲区上画带颜色的背景框还是很简单的。btw, imtui 里还真的写了一个三角形光栅化的代码,我觉得完全没有必要。

有些非矩形的三角形,也很容易判断出是什么方向的箭头,转换为文本字符。其它复杂的集合图形就直接扔掉。


等我把这一切做完,单独测试了一下新的 imgui backend 输出一屏幕的文本图案后。我对整个方案又产生了怀疑。如果做下去,去对接 bgfx api 倒是不难,但是,让 imgui 输出一大堆三角形,再想办法反向解析回来,又有多少意义呢?

其实,我并不需要一个完整的可交互的 UI 界面啊。只是为了调试时在屏幕展示一些信息而已。之前不好用,是因为没有做一个方便的版面编排接口。imgui 我倒是用过一段时间,它的 api 非常好用,尤其是 table api ,可以在屏幕上任意切分区块,在里面安放控件。

如果我只需要这么一个版面控制模块,并只支持文本输出的话,好像就解决了问题。

仔细想了一下,实现似乎也不难。我迅速扔掉了上午写的代码,下午重新实现了一套新的文本信息排版的库。最难的 API 设计部分 ImGui 已经做好了,抄就可以。具体实现几百行代码就能搞定。

https://github.com/cloudwu/textcell

到晚饭时间,基本就写完了。这种一天就能搞定的小玩具,真的是周末最好的消遣。

Ant Engine 开源

作者 云风
2024年1月17日 15:24

我在自己研发的游戏引擎上已经工作了 6 年了。在 2017 年底,我写下了对这个新引擎最初的构想 。现在回头来看,当初的想法居然都落实了,只有一点例外:我们中途把编辑器从 IUP 转移到了 ImGUI 上。

2022 年,我们启动了第一个用这个引擎开发的游戏项目,它是一个和日本公司合作的动作游戏。后来,这个项目没有走下去就取消了。之后,因为我们的引擎开发组喜欢 Factorio ,便想用自己的引擎在手机上重现一个 Factorio Like 的游戏,这一干就是一年多。

现在,游戏的技术部分基本完成,可以验证引擎的可用性(功能完整、性能达标),只是游戏性方面还有不少路要走。简单说就是还不太好玩。

从一开始,我就希望以开源模式经营这个游戏引擎,但同时又觉得没有得到验证的东西不适合拿出来。既然引擎已经初步可用,现在就应该迈开这一步了。

毕竟引擎是公司的资产,原来我是自己创业公司的老板,但现在公司已经被阿里收购。开源并不是我一个人可以做的决定。最近一段时间,我争取到了公司的支持。甚至,公司不仅同意我将整个引擎项目开源,还愿意把我们正在制作的游戏的代码及其美术资产也捐赠给这个开源项目。这可以方便引擎开源后潜在的用户去理解引擎。

关于这个开源引擎,有太多的东西可以写,我这里先摘录一些我在向公司申请开源时的报告中的一些内容。文字上有一些吹嘘之词,也有一些未经核实的臆断,但总体上可以反应我的看法,一家之言,姑且听之:

为什么需要一个新的游戏引擎?

目前市场上有两大成熟的游戏引擎:Unity 和 Unreal 。为什么有商业游戏引擎,却依然选择自研游戏引擎?Unreal 目前开放了源代码,而 Unity 我们公司也采购了其源代码。所以,掌握源码并不是主要原因。

我们公司赖以生存的网络游戏项目,和 Unity/Unreal 这些游戏引擎所面对的游戏项目,有很大的本质不同。即,几乎所有的网络游戏产品,都处于长期维护状态,并不以创意多产为主的。商业游戏引擎,它给产品带来的便利在于可以速成游戏原型,而在长期维护方面并无太多优势。一旦产品进入稳定期,更多的是需要在产品上慢慢维护和打磨,或是更换非程序资源以较小代价制作衍生产品。针对特定产品,拥有一个易于维护的游戏引擎,成本是逐步下降的。无论是 Unity 还是 Unreal ,它们的维护成本都极其巨大。我们甚至已经放弃了 Unreal 的维护工作,专注于 Unity 的维护,但依然成本高昂。对 Unity 增加项目需要的特性、性能优化、相对一个按项目定制的引擎来说要考虑的问题更繁杂。

维护 Unity ,不光是我们公司独有问题。国内各大游戏公司都花了极大的人力做这件事。最流行的游戏:王者荣耀、原神等都声称对 Unity 做了极大的改造,几乎全部更换了其核心组件。这也是自然的选择:一个全功能的游戏引擎,必然包含了大量单个游戏所不需要的部分,这些都会成为长期的负担,而游戏运营时间越长,它所需要的引擎功能越单一,维护人员需要聚焦在特别需要的功能或优化上,对原版引擎做改造以适应。

Unity 原本并不为我们这类游戏网络所设计,它的资源包打包更新、针对移动设备的优化、和脚本语言(Lua)的整合,从来都是各家国内游戏公司所面临的痛点。国内大厂几乎都购买了 Unity 源码,但至今只听说层出不穷的补丁方法,而未见有对这些改造彻底的方案(包括我公司)。拥有源码的访问和修改权限,并不能解决这类问题。

为什么 ANT 引擎可以胜出?

开源界最近些年也有一些开源引擎,例如 Godot 。但是,我们的优势在于,更理解我们的游戏项目到底要什么,并可以依据我们的理念掌控引擎的发展方向,而不是像 Godot 那样,企图复刻一个开源版的 Unity 。我们从 2000 年开始开发网络游戏,几个大的成功项目均使用自研的引擎代码:例如西游系列、陌陌争霸、三国志战略版。只是它们均为 2D 项目(随着项目发展加入少许 3D 技术),这是因为 3d 技术的门槛要高的多,需要更多的积淀。

业内,游戏引擎技术每隔几年就有新的想法涌现,然后对已有的引擎做大的改造。开始越早的引擎,历史包袱越多,改造就越困难。例如 Unity 推行的 DOTS 技术就是为了解决它低下的性能问题,但好些年了也未能彻底推翻旧框架。后来的引擎可以轻装上阵。我们的开发团队即有长年(超过 20 年)的技术积累,又能不受老架构的束缚。ANT 本身也经历了 6 年的开发,踩了太多的坑,交了许多的学费,这样才获得了技术上的自信。

维护良好的引擎能随着公司的发展获得越来越大的成本效益。自研引擎的弊端在于开启阶段的风险(很可能无法按质完成),初始投入巨大;而这一步,ANT 已经迈过去了。

为什么要选择开源模式?

游戏引擎属于产业的基础设施。游戏产业早就过了通过秘密掌握更好的基础设施以获得竞争优势的时代。基础设施最适合开源。例如操作系统领域,几乎所有提供公众服务的系统都跑在开源内核上。越是基础设施,越需要更好的性能,更高质量的实现。增加开发人员对提高质量并无收益,它需要的是领域专业人员。而领域专业人员在整个世界范围都是稀缺品,并非靠招聘就能解决,一个好的开源项目更容易吸引到这样的人才加入。

同类别的开源项目也有竞争,竞争的是用户和高质量的开发人员。而开源游戏引擎(尤其是 3D )因为门槛很高,竞争并不激烈, 在国内圈子甚至是空白。所以现在做此开源项目有先发优势。一个良好的开源项目长期运营,必须凝聚人和项目,要持续有人用、有人持续开发。一旦人捆绑上去,项目就会像滚雪球一样壮大。我们并不需要掌控技术本身的秘密来获得技术壁垒,掌握项目的主导性就可以获益良多。开源项目的良性发展会不断促进项目本身的质量,远超闭源项目。项目的高质量是极其珍贵的。

Skynet 就是一个极好的正面案例。

Skynet 是一个为网络游戏服务的基础框架。 2012 年 7 月开始编写第一行代码,同年 8 月就以开源模式维持到今天。它为我们公司所有的游戏项目提供了高质量的底层保障,有很多 Bug 甚至是未在我们自己的项目上出现之前就被解决掉的。这得益于它有大量的公司外用户。因为它一直以开源形式开发和发展,吸引了大量的用户。现在在 github 上拥有了 12.4K Star 4.1K Fork 以及 123 个 contributors ,大多数 contributor 不是我们自己公司的同事;部分 Contridutor 后来加入了我们公司。

因为 skynet 被各个公司的项目采用,它的应用面远超我们一家公司的应用场合。这使得 skynet 的发展过程中,会非常仔细的考虑各种边角,保持它的精简,避免它演变为屎山。这么多年没有演化为屎山又反过来给它带来更多的用户。目前 skynet 不光是用于游戏服务器领域,在视频网站、路由器管理方面,也有不少应用。

从搜索引擎的结果看, Skynet 在国内网络游戏圈子,已经是众多中小公司的选择。在招聘网站上,很多游戏公司招聘都会有限考虑有 skynet 经验的候选人。从这个角度,它也帮我们节省了巨大的人力成本。我们因 skynet 树立的技术品牌,吸引了高质量的人才;新加入的员工在进入公司之前就用丰富的 skynet 经验,可以更快的融入工作。

对于程序员来说,也更希望在职业生涯中使用一项活的技术,而不是换了公司,换了行业,就放弃了过去的积累。为开源项目工作可以获得更大的成就感,成就感对顶尖的程序员来说至关重要。

ANT 引擎的特色

ANT 专注于移动平台,在移动平台上,不光要保持帧率更要节省能耗。这会导致引擎结构设计上有所配合,这些是我们在设计时时刻考虑的方面。而且,ANT 区别于 Unity ,它尽可能的让开发工作随时保持在真实移动设备上运行,这对在移动设备上做出更好的交互体验,意义重大。

ANT 基于 Lua 开发,有极低的理解成本,和极高的动态可定制性。以往基于 Lua 这类动态语言开发的基础设施中,最难解决是性能问题,而性能恰巧又已被 ANT 解决的相当不错。ANT 在手机上有极好的性能,我们用它开发的游戏,在及其复杂的场景下,iPhone 12 上依然可以小于每帧 10ms 的速度渲染。对于同等复杂度的场景,Unity 实现同等的效率将非常困难。

因为有几十年的网游维护经验,我们深知资源打包更新这些对项目的重要性。ANT 在这方面做了彻底的支持,方便客户端更新。方便很多美术、策划一起共同创作。

ANT 一开始就设计成易于定制的,不用的特性可以轻松的去掉、渲染管线可以轻松改写。甚至可以方便拓展到非游戏应用中。例如腾讯的 QQ 客户端就集成了 Unreal 引擎,而这项集成至少会增加 App 数十兆的体积;而 ANT 引擎本身只需要链接 1M 的二进制模块。

ANT 引擎的现状

已经开发了 6 年,使用这个引擎开发的游戏有一年历史,技术部分已经全部完成(游戏体验部分还需要调整),性能方面超出预期。

目前只有 iOS 版本完全可用。引擎可以在 Windows 上运行,但缺乏一些和 PC 相关的支持(比如接入 Steam ,更好的键盘鼠标支持)。Mac 版本略有不足,Android 版本尚未完成。

引擎缺少引导性的文档,需要在开源发布前编写一些基本的指导。

目前的游戏代码并不是很好的引擎示例,因为它跟随着引擎最近几次重构,残留了许多不太好的实践。而内置例子只用于测试,还相当不完善。我们还需要制作一个更轻量的 Demo 展示引擎特性。


今天,我已经将 Ant Engine 的私有仓库公开。感兴趣的同学可以自由访问。但这个项目尚未正式发布,该有的文档和实例都很不完善。这是我们正在开发中的游戏使用的引擎,所以至少它是可用的,同时也可能随时被修改。

我会在过年前多写一些文章介绍引擎的结构和设计思路,这也是 skynet 开源初期我所做的事情。等年后,再将我们的游戏代码仓库也开放出来。

style 表的结构化访问

作者 云风
2024年1月10日 16:03

我们游戏 UI 基于 RmlUI 的 fork,做了大量的改造。它实际上类似目前的 web 前端技术,使用 CSS 来表示 UI 的布局。所以,我们做的底层工作也都是围绕如何高效实现一套基于 CSS 的 UI 引擎来做的。

一年多前,我写过一篇 blog 介绍了一些优化的工作

最近,在游戏开发的使用中,我们又发现了一些性能热点,最近在着手优化。这一篇 blog 记录一下其中的一个优化点。

按目前引擎的抽象,每个 style 其实是一组 attrib 的列表。而单个 attrib 则是一个 k/v 对。k 虽然使用上是一个字符串,但实际上会被转换为 [0,127] 的数字 id 。也就是说,引擎只支持一百来个不同的 key 。就目前 RmlUI 定义的 css 规范来说,够用了。

而 value ,也是一个字符串。但对于不同的 key 有不同的结构。对 stylecache 模块来说,它并不关心其结构,全部视为字符串。这个字符串根据不同的 key ,可能是布尔量、数字、字符串、也可以是一组数字、甚至是一个字典这类复杂结构。

RmlUI 是用 C++ 编写的,我们把 style 从字符串取出时,转换为一个 C++ 可以方便访问的类。过去,这是通过序列化和反序列化进行的。即,当我们把 attrib 从 style 中取出时,将 attrib 通过反序列化变成一个 C++ 对象。这个对象可以方便的被 C++ 代码使用。

目前,这个反序列化过程发现有潜在的性能问题,值得优化一下。大致有两个方向:

其一,直接把 C++ 对象映射为连续内存,然后将内存视为字符串被 stylecache 管理起来。这样,就省去了反序列化的过程,也无需在每次访问 attrib 的数据时构造新的 C++ 对象(这个对象可以视为数据块的访问器)。

这一步我们已经差不多做好了,的确提升了不少性能。

但是复杂结构,比如字典对象,就很难被映射到连续内存块中。这是因为,复杂数据结构需要额外的索引信息。这些信息在 C++ 语境下,很可能是一些指针。指针是很难被复制和移动的,它难以被视为字符串。

所以,我考虑了第二个方向:不拒绝额外的访问器对象,同时对访问器做缓存。

如果我们把一个复杂的数据结构看成有两种形态,一是一个字符串;二是一个 C++ 对象;两者的信息是完全等价的,当它们都是不变量时,后者就是前者的访问器。

如果 C++ 对象可以通过字符串调用反序列化接口创建出来,又可以调用序列化接口变成一个字符串,那么就可以在两个心态间自由转换。通过这种转换,我们可以简化信息本身的生命期管理。

字符串的优势是可以自由的制作副本,计算 hash ,去重,比较等等;劣势是难以访问内部的数据子结构。

所以,我们对外的接口提到 attrib 时均可以 C++ 对象的形式提供数据,这样可以方便用户数据其内部数据结构。但,当我们需要在内部储存一个 attrib ,则可以以序列化后的字符串形式保存下来,同时 cache 一个对应的 C++ 访问器对象的内部 handle。这个 handle 可以较小,例如,当我们使用一个 16bit handle 时,内部其实最多 cache 64K 个访问器。因为访问器和字符串数据可以自由转换,当 cache 失效时,随时都能重建。

注意,这里没有提到访问器对象的生命期管理。实际上,我们需要调用者提供一个销毁方法,这样才能在 cache 满后销毁掉不用的访问器。但用户完全可以额外给访问器增加引用计数的管理。它的好处是,当通过接口取得一个 attrib 的访问器后,可以通过增加引用来持有它,下次把这个对象再重新传回 stylecache 模块(同时减一次引用)。持有和复用特定 attrib 的成本就是 O(1) 的了。

即:如果从外部传入一个新的 attrib ,stylecache 模块先把它序列化为字符串,持有这个字符串,这样就无需和外部商定其生命期如何管理(信息被复制了一次)。

如果从 stylecache 获取一个 attrib ,得到的是一个生命期仅延续到下一次 api 调用前的访问器,用户不需要关心它的生命期。但接下来如何立刻将访问器指针传回 stylecache 模块,则有可能在内部 cache 中找到,这样就可以跳过序列化流程。零拷贝。


大致是这样的,用户先定义三个接口:

typedef void * accessor_t;  // 访问器,可以是一个 C++ 对象

accessor_t (*create)(const char *, size_t);  // 从字符串构造访问器
void (*release)(accessor_t);  // 销毁访问器
size_t (*serialize)(accessor_t, char buf[], size_t buf_sz);  // 将访问器序列化为字符串

style 层面的接口需要交换 attrib 数据时,都采用访问器类型。所有的接口都约定为:对于输入参数,调用者自己负责输入访问器的生命期;对于输出参数,返回的访问器对象的生命期至少可以维持到下一个 api 调用。这样即没有增加生命期管理的成本,又可以额外提供一个 C++ 访问器对象供用户使用。


我写这篇 blog 是因为觉得以上方案有一定的通用性,值得记录一下:

在以往的 C++ 方案中,如果你想将一个 C++ 对象视为一个基础类型,常见的方案是给它定义一大堆接口:增加引用、减少引用、复制、计算 hash 、对象比较……

这样,这个对象才能被传递、复制、储存在容器中,以及作为字典的 key ,做类似字符串 intern 这样的去重(即相同的值在系统中只保留一份)。

我觉得传统方案过于复杂,需要额外编写大量的代码,且在生命期管理上很容易犯错。而这篇提到的方法,只需要为对象实现序列化、反序列化以及销毁三个接口就够了。而且减少了生命期管理的复杂度。

避免帧间不变像素的重复渲染

作者 云风
2023年12月25日 13:30

上周五在公司内做了一个技术分享,介绍我们最近五年来自研的游戏引擎,以及最近一年用这个引擎开发的游戏。大约有一百多个同学参加了这次分享会,反响挺不错。因为这些年做的东西挺多,想分享的东西太多,很多细节都只是简单一提,没时间展开。

我谈到,我们的引擎主要专注于给移动设备使用,那么优化的重点并不在于提高单帧渲染的速度,而在于在固定帧率下,一个比较长的时间段内,怎样减少计算的总量,从而降低设备的能耗。当时我举了几个例子,其中有我们已经做了的工作,也有一些还没做但在计划中的工作。

我提了一个问题:如果上一帧某个像素被渲染过,而若有办法知道当前帧不需要重复渲染这个像素,那么减少重复渲染就能减少总的能耗。这个方法要成立,必须让检查某个像素是否需要重复渲染的成本比直接渲染它要低一个数量级。之所以现存的商业引擎都不在这个问题上发力,主要是因为它们并没有优先考虑怎么给移动设备省电,而要做到这样的先决条件(可以廉价的找到不需要重新渲染的像素),需要引擎本身的结构去配合。

我没有在分享会上谈细节,是因为我不大想谈还没有做出来而只是在构想中的东西。但我同时承诺会写一篇 blog 展开说一下,便有了现在这篇。

我们尚未去实现这个想法是因为目前引擎的性能已经被优化到比较满意的程度,而完善游戏本身要重要得多。对于只有 3,4 个人的小团队来说,必须推迟一些不重要的工作。

我们的引擎虽然是用 Lua 编写的,但性能瓶颈目前在 GPU 而不是 CPU 上 。比如,开启 PreZ 这个流程,先把几何信息提交到显卡,减少部分重复的像素着色器的运算,就能明显的看出性能的提高。

PreZ 是一个非常流行的优化方法:我们先把需要绘制的对象写入 Z-Buffer ,这样就可以得到当前帧和屏幕对应的每个像素的 Z 值。然后在后续的渲染中,只要没有半透明的像素,都可以先和这个计算好的 Z 相比较,如果它将被后续像素覆盖,那么就不必运行它对应的像素着色器。

PreZ 的算法很简单,就是把场景多遍历并渲染一次即可得到所需的 Z-Buffer 。那么,有没有什么廉价的方法可以得到一张蒙版图,让当前帧相对上一帧并没有改变的像素都在蒙版上标记出来呢?我们可以想象,如果光照情况在帧间没有发生改变(这很常见),摄像机也无变化(除了 fps ,这也很常见),其实每一帧不变的像素其实占有很大的比例。即使是 fps ,摄像机也不是全程逐帧运动的。从节能角度看,我们减少了一个时间段内的大量像素着色器的重复运算,就将是个非常成功的优化。

如何用廉价的方法得到这样一个蒙版:蒙版上的 1 表示这帧不需要绘制的像素,0 表示需要绘制的。这个蒙版不需要生成的非常精确,任何本该是 1 的地方变成 0 都是可以接受的,反之则会导致 bug 。每帧结束后,不要清除 backbuffer ,而在绘制阶段,每个绘图指令都带上这个蒙版(就好比带上 PreZ 生成的 Z-Buffer 一样)就可以渲染出正确的结果。

为了简化问题,我们可以先不考虑阴影(后面再谈有阴影的问题)。那么,每个绘图指令都是对屏幕空间指定像素的直接修改。最终,屏幕上每个像素都被若干绘图指令修改了多次。正如 PreZ 可以帮助快速剔除特定像素上的多个不必要的绘制过程而只保留正确的那一个那样,我们也可以用类似的方法来在每一帧的开头生成蒙版。

为了表述方便,我们把绘图指令分为两类,红色和黑色。红色表示,这是一条上帧没有出现过的绘图指令;黑色表示,这条绘图指令上一帧出现过。这个信息,只要引擎合理的设计,是很容易知道的:任何一条绘图指令,它都能知道其参数是否和上一帧相比发生了变化。如果我们把红黑两色绘制到屏幕上,那么,任何一个像素只要出现过至少一次红色,它最终就是需要绘制的,而如果全为黑色,它就很可能可以保留上次的绘制结果。

有什么简单的途径可以知道某个像素在什么时候没有红色却也需要重新绘制呢?答案是实际绘制上这个位置的黑色的次数。只要次数完全相同,就能保证这个像素一定和上一帧完全相同。

这个算法很容易实现。我们每帧将 buffer 清零。对于黑色绘图指令,在光栅化时把对应的像素加 1 ;而红色绘图指令则加一个极大数。最终,我们比较 buffer 和上一帧 buffer ,将极大值以及和上一帧不同的值设置为蒙版上的 1 ,就得到了变化蒙版。

20 多年前,我在风魂这个 2D Engine 中实现过类似算法。在西游系列的游戏中运用,性能比同时期的 2D 图形引擎好不少,就是因为它可以剔除很多当前帧不必要重复渲染的像素。当然,当时我是在 CPU 中实现的这个算法,而今天改到 GPU 中去做也不算麻烦。

同样,除了 GPU 层面,我们还可以在 CPU 也运用类似算法,减少一些多余的图形指令。只需要把 backbuffer 按比例缩小(比如每个轴缩小 64 倍),得到一个粗略的网格。然后把每个绘图指令涉及的 mesh 投影这个 buffer 上的 AABB 矩形计算出来,用同样的方法把绘图指令记录在网格的每个格子上。最终,我们可以剔除掉那些当前帧和上一帧完全相同的格子。这些格子可以用来得到一个更粗粒度的蒙版,同时剔除掉对应的绘图指令。


阴影怎么办?

我倾向于为每个可以接收阴影的物件单独生成一张独立的较小的阴影图,而不是像传统方式那样,将所有的场景物体渲染去一张非常大的单一阴影图上。

初看这个独立阴影图的想法会觉得性能无论上时间还是空间上都难以接受。因为如果有 n 个物体需要接收阴影,有 m 个物体会投射阴影,那么就需要绘制 n * m 次,并生成 n 张阴影图。

但实际上,单独的阴影图会比流行的 CSM 等算法更简单,也更容易提高单个物体阴影的精度。而大多数情况下,只要场景上的物件大小不是差距很大,且分布均匀的话(第三人称视角的游戏中非常常见),每个物体只会接收其周围很少几个物体的投影。而一个物体受哪些物体的投影影响这个信息,在帧间通常变化很少,所以筛选过程并不需要每帧全部重新计算。所以,生成 n 张阴影图的成本远远不到 O (n * m) ,应该可以优化到 O(n Log m) 左右。

如果再考虑做以上相同的帧间 cache ,对于每个物件单独的阴影图(也只是它材质的一部分),很可能下一帧并不需要重新生成,只需要投影它的有限几个物件没有改变即可。整体的(尤其是能耗)成本很有可能比传统方式更小。

游戏引擎中的可视化编辑器

作者 云风
2023年12月12日 10:52

提起游戏引擎,特别是商业通用游戏引擎,比如 Unreal 或是 Unity ,给人的第一印象会是它们的可视化编辑器。而在实际开发中,在不同引擎下做游戏开发,影响最大的是引擎层的 API 以及这些 API 带来的模式。

而对于使用自家专有引擎开发出来的游戏,却少见有特别的编辑器。比如以 Mod 丰富见长的 P 社游戏,新系列都使用一个名叫 Clausewitz 的引擎,玩家们在之上创造了大量的 Mod ,却不见有特别的编辑器。Mod 作者多在文本上工作,在游戏本身中调试。游戏程序本身就充当了编辑器:或许只比游戏功能多一个控制台而已。在这类引擎上开发,工作模式还是基于命令行。

游戏引擎中的那个编辑器无疑是引擎开发中耗工时最多的部分。我们自己研发游戏游戏已有 5 年 ,其中编辑器完全重做了两次,目前第二个人维护着第三版。在这几年里,我一直在思考:游戏引擎到底需要一个怎样的编辑器、它应该用来解决怎样的问题。

在 20 多年前,我写风魂的时候,它受 Allegro 的影响最多。当时,只要封装出 API ,解决图形、声音、键盘、鼠标、系统窗口等的底层调用就解决了游戏开发中最难的部分。后来,到 2001 年开发大话西游,我根据游戏的需求为引擎写了几个小工具,用来编辑场景、2D 动画等等,支撑了游戏开发。那些工具是为游戏定制的,同样定制的还有一些对应的程序模块。我认为不属于引擎的范畴。

后来,各大游戏公司纷纷转向了商业游戏引擎,家酿引擎越来越少了。我在 2005 年时开发 3D 引擎时,也受那些商业引擎的影响,觉得游戏引擎必须要有一个大而全的编辑器。如果缺少这个,似乎没有人愿意用它开发游戏。2017 年底,我重启游戏引擎计划时,依旧觉得,开发一个编辑器非常重要,否则游戏引擎很难吸引开发者。

但是,别的引擎有什么,我们就应该做什么。这绝对不是一个好主意。因为复制似是而非的特性并不能真正解决问题。我们首先需要理解问题。一个功能丰富的游戏引擎编辑器看起来是为了减少编码 (low code ?) ,降低游戏开发的难度,让不太会写程序的人充分发挥他们的创意。

但这并不符合我们自己的需求。因为,我们项目组的所有人都有丰富的编程经验,code 并不是难事,不用会、少用 code 做同样的事反而增加了开发难度。对于一个软件项目来说,开发者必须是第一用户,Dogfooding (吃自己的狗粮) 对软件开发尤为重要。在软件开发这么多年中,我学到的最重要的一点就是:如果一个特性你不常用到,那么就应该立刻从代码中删除,直到以后用到了再加回来。所以,我们编辑器的第一次完全重构就是因为抛弃了复刻一个 Unity 编辑器的想法。我们做引擎绝对不能因为别人有什么而做什么,用户也不会因为这个引擎也有同样的功能而选择它。如果我们自己开发游戏不会用 low code 的模式开发,那么我们就不应该做一个以减少编码为目的的编辑器。

那么,是不是意味着我们的游戏引擎不需要一个丰富的可视化编辑器?只需要把 API 设计好,可以方便的用代码构建游戏就够了?

有一段时间,我们在引擎开发上是这样的:用简单的几十行代码就可以搭出一个小 demo ,测试或展示某个特性。但这会让引擎停留在渲染层上,离做游戏还很远。最糟糕的是,这些 demo 代码中充斥着 magic number :摄像机的角度、灯光的参数、硬编码的文件名…… 不可忽视的是:游戏中大量的内容是以数据形式表达的,而不是代码。数据最终呈现的是画面效果,它们需要根据视觉效果调整。一个可以快速启动的程序能够改善调整这些数据的体验;但通过文本编辑器修改这些数值绝不是高效的方法。所以、我们需要可视化编辑器。

游戏引擎的编辑器:是一种用来产生游戏数据的可视化工具。这些数据如果可以用更成熟的工具产生,那么就不必将功能集成在编辑器中。例如,我们并不需要引擎的编辑器做 3d 建模,也不需要有笔刷像 photoshop 那样绘制贴图;同样,集成一个代码编辑器编辑脚本的意义也不大。它最重要的作用在于把代码逻辑和数据分离。一个好的编辑器可以产生出数据,然后引擎的代码只需要读入这些数据就可以创建出游戏中的实体。

同时,引擎的 API 应该为之简化。在缺乏编辑器时,引擎 API 层提供的大量 API 都是用来让代码可以正确的构造游戏实体。这些 API 反应了各种数据是如何控制每个细节的。但有了编辑器创作好的数据后,数据已经代码运行之前就组织成应有的复杂结构,所以,引擎只需要提供单个 API 加载这些数据就够了。所谓编辑器,编辑器的就是某种预制件(prefab),预制了最终运行时的数据结构。游戏的大部分数据是用于视觉表现的,所以需要一个可视化的手段编辑和呈现。

编辑器产生的数据是引擎运行时的输入。这些数据应该是易读的,但不必是易于(用文本编辑器)编辑的。我们设计了一个专有格式 来描述游戏引擎中的大部分数据。尽量把代码逻辑(用 lua 编写)和数据分离。引擎提供的 API 中很少有特别细节的控制接口,所以,脱离编辑器制作一个游戏 demo 是很难的。因为开发者缺乏直接控制构建数据的 API ,难以硬编码摄像机位置、灯光信息、角色的空间状态在代码里,最佳的途径是加载一个编辑器编辑出来的预制件。这样,也促进了我们吃自己的狗粮。避免编辑器成为游戏引擎的一个边缘子项目。当然,因为编辑器产生的数据还是文本的,硬去手写一个预制件也不算太困难。

在搭建了最终的编辑器框架后,我们更多的是在实际游戏项目开发中遇到问题,就顺手给编辑器加一点功能。例如,编辑一个摄像机运镜的轨迹、写太阳一天的日夜循环模拟,方便美术调整光照变化的参数,等等。这些功能都是为实际游戏服务的,并没有打算做成通用引擎的一部分。

鉴于我们现在开发的游戏中并没有使用物理特性,前两个月便把曾经集成好的物理模块又去掉了。同时去掉的当然还有相应的编辑器功能。而预制件的动画及特效的时间轴编辑则在不断完善,因为美术总有一些需求,原来是在外部工具(例如 blender )中编辑好再想办法把数据导入引擎,慢慢的却发现在我们自己的编辑器中编辑有时更方便。只维护现在游戏项目用得到的特性,我想这才是一个好的状态。

游戏数据包的补丁和更新

作者 云风
2023年11月30日 17:00

我们的游戏引擎是基于虚拟文件系统,可以通过网络把开发机上的目录映射到手机上。这对开发非常方便,开发者只需要在自己的开发机上修改资源,立刻就能反应到手机上。

但当游戏发行(也就是我们正在准备的工作),我们还是需要把所有资源打包,并当版本更新时,一次性的下载更新补丁更好。

之前一直没时间做这方面的工作,直到最近才考虑这个问题。我们到底应该设计一个怎样的补丁更新系统。

我不是第一次设计这玩意,早在 20 多年前我就为大话西游设计过一个。但我这次想重新思考这个问题,用一些更标准的技术来做,比如,使用标准的 zip 包格式,而不是自己重新设计。

当然,怎么把文件打包是次要问题,主要问题是怎么解决版本间的差异更新。用户可能停留在不同的版本上,都应该可以正确更新到最新的版本。如有可能,还应该支持版本回滚。

传统的方法是用一个递增的版本号,打包时,仅打包版本间的差异。用户要更新版本时,下载从本地版本到最新版本间的所有 patch 文件,按严格的次序依次打包。我觉得这个方法固然没什么大问题,但不是特别好。因为它不够健壮,缺失一个 patch 就会让升级无法完成。而频繁的版本更迭会导致太多的 patch 。虽然可以定期打包一个全量的包来阻止太多的 patch 文件,但也只是个不太干净的补救手段。版本回滚和分支版本发布都会比较麻烦。


我们的 vfs 系统其实是一棵 Merkle tree 。每个文件的文件名就是它内容的 hash 值。而整棵树的根的 hash 值就是一个天然的版本号。(btw, 它天然是防篡改的。)所谓打包,就是把当前版本的整棵树的文件打包为一个包文件。这个文件的文件名可以就是它的根的 hash ,也就是版本号。

所以,版本号不需要是递增的数字,这样,从一个版本切到另一个版本,也不用区分是更新、还是回滚、亦或是分叉。git 就是这样管理版本的,我们的 vfs 也一样,只不过现在要处理如何打包补丁的问题。

所谓补丁,我们是为了减少更新的带宽,减少用户设备上的存储空间。因为 vfs 中文件的文件名就是内容的 hash 。所以找到补丁和上个版本的差异,只是找到那些新增的文件即可。假设在打包机器上已经有很多历史版本的包,那么,我们需要做的就是用当前版本的完整列表和历史版本包文件内列表相比较,找到新增文件数量最少的那个,并打包新增加的文件即可。

在包里面,可以在补上一点元信息:这个包是补丁包,它的完整版本还依赖另一个版本 hash 。

用户在更新时,一旦需要切到某个特定版本(更新服务器上有所有版本的列表以及建议的最新版本),就下载那个版本的 hash 名的文件即可。下载后,检查元信息,看看所依赖的版本 hash 本地是否存在,如果不存在,再重复前面的过程。

这样更新的好处是,完全兼容平时开发中的 vfs 同步。如果我们用开发版本同步过某些历史版本(这些版本未必发布过更新补丁),再下载更新补丁的话,也能顺利的找到需要的补丁文件,把本地资源补全到完整版本。


这个方案中,不再区分完整版本包和补丁包。它们都代表了某个特定版本,只不过包内数据全或不全。我们在包的元信息中记录三样信息:

  1. 这个版本的根 hash 是哪个文件。一般同时是包自己的文件名,但这个信息不应该依赖包的文件名,所以也记录在包内的元信息里。这样,包文件名就可以任意发挥。

  2. 这个包的数据不完整的话,数据还依赖哪(几)个 hash 版本。

  3. 这个包依赖哪个版本的二进制执行文件。这个通常是源代码的 git hash 版本号。因为执行文件是不打包在资源包里的,所以需要单独注明,已便运行时校验。

Lua 的 C 模块之间如何传递内存块

作者 云风
2023年11月24日 16:08

Lua 的数据类型非常有限,用 C 编写的 Lua 模块也没有统一的生态。在不同模块间传递内存块就是件很头疼的事情。

简单通用的方法就是用 Lua 内建的 string 类型表示内存块。比如 Lua 原生的 IO 库就是这么干的。读取文件接口返回的就是字符串。但这样做有额外的内存复制开销。如果你用 Lua 编写一个处理文件的程序,即使你的处理函数也是 C 编写的模块,也会复制大量的临时字符串。

我们的游戏引擎是基于 Lua 开发的,在文件 IO 上就封装了自己的库,就是为了减少这个不必要的字符串复制开销。比如读一个贴图、模型、材质等文件,最后把它们生成成渲染层用的 handle ,数据并不需要停留在 Lua 虚拟机里。但是,文件 IO 和资源组装(比如贴图构造)的部分是两个不同的 C 模块,这就需要有效的内存交换协议。

我们又不想让所有的 C 模块统一依赖同一个自定义的 userdata 类型。例如 bgfx 的 Lua binding 就是一个通用模块,不一定只在我们这个游戏引擎中使用。引入一个特定的 userdata 感觉不太好。

所以,我倾向于协定一个数据交互的协议,而不是共同依赖同一个库实现的特定用户类型。

首先,用 string 交换内存块肯定是最通用的协议,它的问题是低效,有无谓的内存拷贝,多余的对象需要通过 gc 清理。

我们很早就给几乎所有的 C 库增加了 raw userdata 的支持:即把不带 metatable 的 userdata 视为普通的 string 。userdata 和 string 在 Lua 的内部实现中也非常类似,均可以表达一个带长度的内存块,区别在于 userdata 的数据是可变的,string 的数据是不变的。

我在很多自己编写的 C 库中增加了第三种协议,用一个 lightuserdata + integer 表示一个内存地址和长度。比如 skynet 的 C 库就支持这种协议。这个协议的问题有两个,其一参数变成了两个,和单个 string 或 userdata 不一样,处理起来非常麻烦;其二,无法管理 lightuserdata 的生命期。

为了解决生命期管理问题,在实现 bgfx lua binding 时,我又增强第三种协议:在内存地址和长度之后,允许再增加一个叫 lifetime 的 object 。如果需要管理生命期,Lua 侧就把这个对象引用住,不再使用那个地址后,就解开引用。当这个 lifetime object 是 string 时,我们就可以用前面的 lightuserdata 指定字符串内的子串,而不需要真正构建一个新的字串对象了;这个lifetime object 也可以是带 gc 元方法的 table 或 userdata ,负责最后释放内存指针。

今天,我们又重新审视了这个问题。动机是这样的:

过去,我们在每个线程(独立虚拟机)中分别做 IO 。这样,我们自己实现的 IO 库可以使用上面的第二种协议返回一个 userdata ,传递给其它模块使用。最近,我们想把 IO 全部挪到唯一的 IO 线程做,它读取数据后,再传递给请求方。这样,就涉及虚拟机间的数据传递。

在上面第二方案中,raw userdata 必须在同一虚拟机内创建再使用,无法接收外部传来的数据。而换成第三方案(在我们现在的游戏引擎中并未使用过)又没有很好的解决第一个问题:多于一个参数和单个 string / userdata 不同,会让协议实施起来很麻烦。

考虑再三后,我觉得可以引入第四个方案:用单个 lua object 承担内存地址、长度、生命期管理三项数据。

简单说,我们需要一个 tuple ,把三元组打包在一起。Lua 中可以用来表示 tuple 的有三种东西,table (array) ,userdata + uservalue ,function closure 。因为 raw userdata 已经放在第二方案中使用,我不想和它冲突,那么可选的就是 table 和 function 了。我觉得 function 最为合适。

当传递一个 function 时,我们用 lua_call(L, 3, 0); 调用它,就可以拿到一个三元组。前两个就是内存地址(lightuserdata)和长度(integer);第三个是可选项,用来管理这个地址的生命期。进一步,当这个生命期对象是另一个 function 时,我们还可以直接在使用完内存后调用一下这个关闭函数,解除内存的引用;或者(当它不是 function)和前面第三方案一样,依赖这个对象的 gc 清理内存。

❌
❌