Kirino 的游戏宝藏
← 返回第 022 期
游戏观察

按种类付费,Naio Games 如何用纯 GDScript,在 Godot 4 里扛住数百敌人与子弹

Naio Games《Hexwright》Godot 4 纯 GDScript 群潮指南(约 1000 敌 / 778 弹、过约 120 敌帧率 48→32);按种类付费:对象池、一类一管家、假灯光、网格碰撞、图集、MultiMesh、GPU 直线弹与实测数字。

我的收藏 ↗

如果说过去几年幸存者式、子弹天堂与塔防共用的制作默认,是给每个敌人、每颗子弹、每件拾取各自挂一套脚本、一套灯光、一套碰撞,那么 2026 年十月十五日,独立工作室 Naio Games 放出的那篇约十八分钟长文,等于把这套默认的账单摊开给同行看。他们写的是自己在 Godot 4 里做 spell-crafting roguelite《Hexwright》时的实测。目标场面可以到约一千敌人、七百七十八抛射物、近一万节点。过了约一百二十个敌人,帧率从四十八掉到三十二。原因很朴素,过了那个点,每帧会跑两个 physics tick,所有按敌人结算的活都翻倍。

我把他们的公开指南全文对了一遍。能钉死的事实并不复杂。整套技法写在普通 GDScript 与 Godot 自带着色器语言里,没有 C#、没有 C++ 模块、也没有 GDExtension。约束之一是他们要出 web 构建,C# 的 web 导出当时对他们不可用。日志里真正拖慢的,往往也很少是 GDScript 本身。拖慢的是引擎被要求对每个对象各做一件小事,再乘上群潮的规模。改的是这份工作的形状。

贯穿本稿的意象,是按种类付费四个字。它听起来像会计术语,落到帧预算上,却是一组可核验的改账。

Naio Games 性能指南 OG 图

一、行业默认,每件东西单独付账

幸存者式与子弹天堂的形状很固定。种类不多,每种重复几百次。于是每个对象上多出来的一毫秒灯光、一次回调、一次纹理切换,都会被群潮放大成整帧灾难。行业默认写法也很固定。子弹自带 `_physics_process`,敌人自带 `Area2D`,拾取自带 `PointLight2D`,拖尾用一条 `Line2D`。引擎会替你把这些东西接进物理、渲染与信号总线。你写起来顺手,账单却按件数开。

Naio 在文里给过一组刺眼的局部账。八百八十个拾取灯光,曾单独约占五毫秒每帧。敌人对玩家的碰撞检查,约占敌人成本的百分之五十四。三百颗子弹穿过二百四十个敌人时,开命中是七点九毫秒,关命中是四点九毫秒。三百条子弹拖尾,从十三点七毫秒掉到五点零毫秒之前,draw call 从三百一十六掉到二十八。这些数字都来自他们自己的 playtest 日志与基准场景,机型不同会变,但方向很清楚。贵的常常先落在「每件东西单独付账」这件事本身,很少先落在某一段聪明的 AI 上。

默认逻辑与他们的分叉点就在这里。默认问的是,怎么给每个对象写完行为。他们改问的是,同一种东西能不能只付一次管理费,对象只付自己那份数据。

指南页战斗场面海报

二、改记账方式,对象池、一类一管家、假灯光与网格数学

第一刀落在创建与销毁上。战斗中途 `instantiate()` 会搭节点树、跑 `_ready()`、登记物理与渲染。`queue_free()` 再拆一遍。五十次挤在同一帧,例如一次霰弹技能或一波敌人同时倒下,玩家会看见卡顿。做法是加载时按设计预热对象池,战斗里只 acquire 与 release。场景写成惰性初始,隐藏、碰撞关闭,挂到树上后再 `activate()`。每次复用要把视觉状态写全,包括默认值,否则回收的火球会带着上一世的辉光。隐藏节点上的物理插值会被忽略,显示前要重置,否则子弹会从上次死亡的位置滑进来。池 miss 要记日志,不要静默砍技能。

第二刀落在回调形状上。原先三百颗子弹各跑 `_physics_process`,就是三百次引擎回调。改成子弹只有 `step(delta)`,由一个 `bullet_manager` 反向遍历列表统一推进。释放用 swap-remove,常数时间,不挪数组。暂停若用 `time_scale = 0`,管理器应立刻返回。他们踩过的坑是,子弹与敌人停了,区域法术与光束仍在以零速度扫,升级界面上白白烧掉六到十四毫秒。同类思路也用在绘制上。两百个敌人各挂状态图标,可以改成一个抽屉在单次 `_draw()` 里扫过去。

第三刀落在灯光。真实 `PointLight2D` 会给它碰到的每个精灵加绘制成本,精灵还有可接收灯光数量上限。五十颗发光子弹飞过人群,等于把人群成本再乘一遍。他们只给玩家、Boss、营火这类少数源保留真灯。子弹与拾取改用加性混合的软圆精灵做地面辉光,同纹理同材质以便合批。夜间 `CanvasModulate` 压暗时,用全局着色器参数把精灵颜色乘回,夜里暗到一半就提亮两倍,观感仍可读。八百八十个拾取灯单独约占五毫秒,是这条改账最直接的证据。

第四刀落在碰撞。若你真正需要的只是圆里有谁,或线段扫过谁,空间网格加距离判定往往比物理引擎配对更便宜。做法是每 tick 把敌人丢进六十四像素格子,查询时只看覆盖到的格,再对少数候选做精确距离。敌人可以离开物理引擎,命中逻辑仍走原来的 `on_hit`。稳定身份要用 instance id,不要用列表下标,因为 swap-remove 会让下标指向别人。Boss 半径是专门陷阱。查询为了不漏掉大怪,必须按最大半径外扩。一个约四十五像素半径的 Boss 进场,全场子弹查询都会多扫格子。他们把半径大于约三十二像素的大怪单独列表,普通查询扫完格子后再补查这几个。六百子弹、六十敌、一个 Boss 的场面,physics 从十七点四到十八点三毫秒落到十五点五到十五点七毫秒,大约省两毫秒。文里写明,省下的只是一部分,其余成本仍在命中处理本身。

这四刀共享同一条原则。对象池消掉战斗中的创建税。一类一管家消掉按件回调税。假灯光消掉按件光照税。网格数学消掉按件物理配对税。玩法侧的命中规则尽量原样留下,变的是谁在付账。

Hexwright 夜战箭雨截图

三、数字继续往下压,图集、MultiMesh、只重绘变化、GPU 直线位移

纹理合批是二维里往往最便宜的一刀,几乎不用改玩法代码。Godot 只在相邻精灵共用纹理与材质时自动合批。走路怪若八方向乘八步各是一张独立图,二百四十个怪可以摊成二百四十一个 draw call。打成带透明边距的 atlas 之后,同一场面掉到两个 draw call。他们测到帧时仍约二点二毫秒,在快 GPU 上更多是余量,未必立刻变成可见帧率跃迁。边距要留够,轮廓着色器才不会采到邻帧。材质同样会拆批。两百个敌人里三只挂特殊材质,多付三批。每只各挂一份,就付两百批。

再往下,成百上千个相同小物件,拾取、火花、拖尾段,若不必与其它物体前后排序,就不必各占一个节点。他们把 MultiMesh 直接挂到 RenderingServer,实例数据只在落地、被拾取这类变化时写入。静止拾取的上下浮动交给 GPU,脚本成本为零。拖尾同理。每颗子弹一条 `Line2D` 就是每颗一个 draw call。改成一个 MultiMesh 里的环形段槽,子弹每 tick 盖一个带时间戳的段,着色器按年龄收细与淡出。三百箭基准里,整帧十三点七毫秒落到五点零毫秒,draw call 三百一十六落到二十八。

技能树是另一类贵画面。整网六边形、连线、等级刻度、图标、价格与名字大多静止,只有少量液体动画。原先整网每帧 `queue_redraw()`,全量升级后要四十九点九毫秒每帧,其中约三十六毫秒是 GDScript 在重发同一批绘制命令。节点扩到四倍时,一百一十六毫秒。改法是把静区切成 tile,各留自己的 canvas item 命令列表,只在脏时重录。平移缩放只动 tile 的变换。薄薄一层 live layer 每帧重画真正在动的东西。今天八十七个节点从四十九点九毫秒落到十点七毫秒。四倍节点从一百一十六毫秒落到十五点二毫秒。平移时从每帧全量重绘变成零次重绘。同一思路再升一级,是渲染分辨率。`canvas_items` 拉伸会按屏幕像素画,四 K 相对一千二百八十乘七百二十大约九倍像素,全屏辉光与着色器都要买单。他们做成可切换设置,`viewport` 模式按设计分辨率画完再放大,给玩家性能七百二十与锐利原生两档。

GPU 位移是最容易写过头的一刀。直线弹只要起点、速度与出发时间,着色器就能每帧摆到正确位置,脚本不必每帧改变换。约八百颗发光弹,身体加光晕加地面辉光,draw call 从约一千一百五十落到约四百八十。帧时在他们的基准噪声里,三百支朴素箭几乎不变,因为同材质同精灵本来就会合批。真机玩法咬回来了。大量弹会追踪、绕轨、回旋,每 tick 都在弯,锚点每 tick 都要重写,反而多出四到六毫秒,比普通节点更糟。于是只有直线弹走 GPU 路径,其余留旧路。文末也写明,直线弹 GPU 绘制、拾取 MultiMesh、以及三十赫兹交错思考,当时尚未完成完整试玩验证。其余条目来自他们本机实测,读者机器上的数字会不同。

Hexwright 火焰箭雨实机截图

四、预热、交错思考,以及不改玩法这条硬边界

加载幕布还在时,他们把特效实例摆进视野里跑几帧,逼着色器管线先编译。屏幕外的东西会被剔除,永远编译不到。对象池按设计峰值预热,投射物数乘释放频率再乘存活秒数。第一次火球卡顿,往往是材质初次绘制才编译。Windows 构建默认 Direct3D 12,偶发一百到六百毫秒、代码与渲染都空闲的停顿,常与 GPU 管线编译有关。启动参数可切 Vulkan,Steam 启动项也能做成第二入口。

思考可以比移动更稀。移动每 tick 都要跑,否则群潮会抖。决定往哪走不必。两半敌人隔 tick 思考,相当于三十赫兹,各自带上距上次思考的累计时间,冷却仍按真实时间走。Boss 与正在起手的怪仍每 tick 思考,否则会穿帮。文里给的量级是,一次思考约占一只怪八点七微秒里的一点八五微秒,一千只大约省零点九毫秒每 tick。交错还要扛住死亡。分离检测若每 tick 只扫一半,死亡却强制全量重扫,而每 tick 都有东西死,收益会被吃掉。把已算结果跟着怪物带走,分离成本从五点四毫秒落到二点九毫秒。浏览器大约慢一点四到一点七倍,他们用 `.web` 覆盖给网页更小的群潮上限。

整篇文章反复回到同一条硬边界。优化可以换实现,不能偷偷改手感。网格命中仍要进入时打一次。暂停时拾取要冻住。视觉侧重建要对齐原来的命中处理。Boss 进场要单独测。物理回调里不要当场增删物理对象。`Max Physics Steps per Frame` 他们用二,避免一帧慢再连追多步雪崩。编辑器 F5 调试会让 GDScript 慢两到三倍,三十一到三十五毫秒对上十一到十九毫秒。正式数字要在玩家拿到的 release 导出里量,可加 `-- --perflog`。忙机器会说谎,同一次基准可以九点四对三点零毫秒,差三倍,所以要 A、B、再 A。无头模式量不到 draw call、灯光与合批。平均值会藏卡顿,最差帧必须一起记。

于是「按种类付费」对独立团队的可执行含义,其实很具体。先分清你有数百份的是哪一类东西。灯光、拖尾、回调、物理配对、纹理切换、整屏重绘,哪一项在日志里最大,就先改哪一项的付费形状。用基准场景做开关,分别关掉命中、拖尾或视觉,一次只回答一个问题。别一上来幻想 C++ 模块能救命。他们的日志说,慢的常常是「每对象一小步」被乘了几百次。

收束,独立在改账本里被重新计价

把这篇指南放回 2026 年秋天的 Godot 制作现场,它首先打击的是一种隐蔽的制作惯性。大家默认优化等于换更快语言,默认性能问题会在「以后写原生插件」时消失,默认幸存者式的卡顿是类型税,只能靠砍数量认命。Naio 把另一条路径摊开。语言可以仍是 GDScript。贵的是工作形状。把按件付的账改成按种类付,帧预算会跟着动。

他们自己的检查表几乎可以当工单用。子弹或拾取上挂真灯,就试加性辉光精灵加夜提亮参数。每弹一条 `Line2D` 拖尾,就试 MultiMesh 段。战斗中 `instantiate` 与 `queue_free`,就试预热池。每个池化对象自带 `_process`,就试一类一管家。每个敌人与子弹都是 `Area2D`,就试网格加圆或胶囊数学。动画帧各一张图,就试带边距的 atlas。成百相同小精灵,就试 RenderingServer 上的 MultiMesh。脚本每帧推动每颗弹,就试实例数据里的锚点,只在变化时重写。特效首次播放卡顿,就试加载时预热。大而多静的界面每帧 `queue_redraw`,就试保留 tile 加薄 live layer。四 K 屏在画七百二十美术,就试渲染质量选项。每只怪每 tick 决策,就试三十赫兹交错,移动仍每 tick。网页构建仍用桌面群潮上限,就试 `.web` 覆盖。只凭感觉卡开工,就先从真机对局拉一份性能日志,修最大的那个数。

可执行的提示很短。

  • 先写你的「按件数放大」清单。灯光、回调、物理、draw call、整屏重绘,哪三项一旦乘上数百就会死。优化时它们优先改形状。
  • 搭一个可复现的基准战,带开关。分别关命中、拖尾、视觉,只回答一个问题。同机连跑 A、B、再 A。
  • 换碰撞或绘制实现时,同步回归「进入打一次」、暂停冻结、Boss 半径。画面与判定要在同一次提交里一起验收。
  • 直线弹可以上 GPU,追踪与回旋先留 CPU。预热池与着色器,但别用砍技能去遮 miss。玩法规则写进测试,比写进感想可靠。

十月十五日这篇长文不会让子弹天堂变简单。它只是把你在为哪种重复付费这件事重新标了价。愿意改账本的人,现在至少有一份带前后数字的公开作业,以及一句已经写进文末的提醒。先量最大的数,再停掉按件付费。

小光

来源与延伸阅读

Naio Games(2026-10-15)Godot 4 Performance in Pure GDScript, Many Things on Screen, No Lag

Hexwright 产品页(spell-crafting roguelite,开发中)https://naiogames.com/hexwright

Naio Games 工作室首页 https://naiogames.com/

登录后,留下你的阅读足迹。

在个人页查看自己的点赞和收藏,换设备也能继续。

登录