做Gal开发的人,多数都会在“用现成引擎”和“自研引擎”之间摇摆过。Ren'Py上手快、资料多,吉里吉里在日本作品里几乎是标配,Unity/Godot也能凑合着拼出一套流程。但真到了做商业项目、要精细控制演出节奏和文本排版的时候,这些方案总有一两处让人别扭的地方。所以我们团队花了一年多时间,从渲染层到脚本层做了一个叫NarraLeaf的引擎。这篇文章不打算写成宣传稿,只想把当初的选型思考、技术取舍和实际踩过的坑摊开聊一聊,给还在纠结引擎选型的人一点参考。
1. 主流Gal引擎生态里,我们觉得别扭的地方
1.1 先看主流方案:Ren'Py与吉里吉里的代价
Ren'Py是目前中文社区最有群众基础的Gal引擎。Python脚本加简单标签,一周内能写demo,教程一堆。但它的问题是:渲染和UI框架是绑定的,复杂演出要绕开默认层去写,性能天花板也低。说直白点,它对轻量文字游戏非常友好,但想要做镜头缩放、多角色立绘分层、粒子特效和高速演出脚本同时跑,就得不断从Python回调里借路,代码越写越像补丁摞补丁。
吉里吉里(KAG)是另一座大山,很多经典作品都跑在它上面。KAG的标签式脚本确实能写出漂亮的演出章节,但KAG大项目管理起来很痛苦:脚本拆文件后依赖关系不清晰,TJS脚本的学习曲线比Python陡,而且对现代编辑器支持差,工程结构还停留在十几年前的思路。我们内部用吉里吉里做过原型,结论是“老将能打,但不适合想快速迭代的团队”。
1.2 四类引擎方案的横向取舍
我把团队调研过的方案整理成一张小表,方便对比:
| 方案 | 上手成本 | 演出自由度 | 长线可维护性 | 跨平台资源可控性 | 团队适配度 |
|---|---|---|---|---|---|
| Ren'Py | 低 | 中 | 中,复杂项目容易写乱 | 一般 | 小团队原型 |
| 吉里吉里 | 中高 | 中高 | 偏低,工程陈旧 | 一般 | 熟悉TJS的老团队 |
| Unity自研 | 高 | 高 | 高,但需要补大量基础设施 | 高 | 工程能力强 |
| NarraLeaf | 低(内部DSL) | 高 | 高(数据驱动) | 高(统一管线) | 想兼顾效率与表现 |
Unity本身是通用引擎,做个文字冒险游刃有余,但它的通用性也意味着你几乎要从零搭一套叙事框架:对话状态机、存档数据结构、文本渲染管线、立绘分层系统。这些模块不是不能做,而是做出来之后还要长期维护,摊子太大。Godot类似,虽然脚本好写,但针对叙事游戏的高级功能同样缺得厉害。
1.3 为什么自研不是“炫技”
自研引擎最容易被人误解成“刷技术存在感”。实际上我们算过一笔账:一个立项两年的Gal团队,光是在Ren'Py上定制演出系统、在Unity上搭叙事框架的时间成本,就已经超过我们自己写一个Mini内核的工作量。更关键的是,现成引擎很难满足我们对文本排版和动效细节的偏执。我们想要的是“台词即数据,演出即状态”,这个出发点决定了框架必须从骨子里围绕叙事结构设计,而不是在通用引擎上事后修补。
2. NarraLeaf的核心设计:把叙事数据化
2.1 台词、支线和演出全部是数据
NarraLeaf的核心理念特别朴素:把一段视觉小说拆成“线性的剧情单元+分支条件+演出指令”。每一句台词、每一个分支选项、每一次镜头切换,在内部都表达成可被遍历和跳转的数据节点。这样做有几个直接好处:存档可以做成节点ID加变量快照,而不是傻存整个场景状态;剧情编辑器可以精确锁定某一行台词;演出脚本和剧情逻辑分离,文案改动不会牵动程序逻辑。
这个设计说起来简单,但很多引擎恰恰在这一层没想清楚。Ren'Py本质上是把剧情写成一段顺序执行的Python脚本,分支多了之后,状态管理会自然膨胀。我们用一种目录形式的脚本语言来写剧情,编译之后生成一张带跳转关系的节点表,运行时只在这张表上做状态迁移。等到游戏跑起来,其实程序根本不需要“读脚本”,它只需要在线性推进节点和检测分支条件。
2.2 从一段NarraLeaf脚本看语言设计
脚本语言的高级感不是来源于眼花缭乱的语法,而是来源于匹配日常写作习惯。下面是小场景的完整脚本:
scenario ch01_opening { scene "bg/beach_day.png" with fade(1.2); bgm "audio/morning.ogg" volume 70; narrator "潮水退下去之后,沙滩上留下一片湿漉漉的脚印。"; show "char/liu.png" at left; liu "你真的决定要去那里吗?"; choice what_to_do { "先等一等" -> goto wait_branch; "直接走过去" -> goto go_branch; } label wait_branch { liu "也好,再听一会海风。"; goto merge_point; } label go_branch { liu "那走吧,别回头。"; goto merge_point; } label merge_point { liu "不管是哪条路,终点都是一样的。"; scene "bg/beach_night.png" with fade(0.8); } }注意几个细节:choice块后面不是else分支,而是显式跳转;所有演出都写成前缀指令;变量操作放在@set注解里,不混在剧情行中。这样的结构让脚本阅读者(通常是文案策划)不用理解代码逻辑,就能改对话、调选项顺序。
2.3 运行时状态机与存档建模
引擎运行时实现了三层状态:剧情节点栈、变量表、演出缓存。剧情推进时,运行时从编译后的节点表里取下一条指令,根据类型分发到不同的处理器。遇到choice节点,就暂停推进,弹出选项面板等玩家输入;遇到goto节点,直接进行栈顶跳转。
存档的设计也因为这种数据化收益很大。NarraLeaf的存档序列化结果很小,通常只有几十KB。原因是它只保存当前节点ID、变量表、以及一小段历史操作记录(用于文本回放功能)。相对地,Ren'Py的存档往往要复制整个游戏对象状态,随着游戏推进,存档体积和保存耗时都会上升。我们还额外实现了“剧情树漫游”调试工具——在编辑器里输入任意节点ID,就能直接跳转到该处运行,这在测长线分支时省了无数遍点击。
3. 渲染和中文文本排版的具体实现
3.1 不堆特效,但让每帧绘制都高效
Gal引擎的渲染需求是“半静态”的:场景图、若干层立绘、少量粒子,大部分时间屏幕上没有高密度动态物体。针对这种场景,NarraLeaf的渲染核心采用了2D精灵混合排序方案,大体上做了三层批处理:背景层只上传一次纹理,不参与逐帧重绘;立绘层缓存顶点缓冲,位移和透明度变化在着色器里完成;粒子层单独做动态合批。
实际测试中,我们在一台GTX 1060的机器上同时显示6张2048x2048的立绘纹理,并叠加60fps的粒子效果,整场景CPU帧耗时可以压在3毫秒以内。原因很简单:vulkan后端开启设备纹理绑定,绘制批次从上百次降到了十几次。对Gal这种画面常驻型游戏来说,资源占用远比“特效炸裂”重要,因为大多数玩家跑游戏时后台还开着浏览器、聊天软件,我们不能拿主机游戏的标准去占用GPU。
3.2 中文文本排版,才是Gal引擎的隐形分水岭
英文视觉小说的排版需求很少,但中文文本面对的问题是细碎且刁钻的:全角标点的压缩、破折号占位、行首行尾禁则、中日韩字体间的基线对齐、文本描边和阴影的叠加顺序。这些功能Ren'Py的原生渲染器做了一些,但到自定义排版风格时明显心有余力不足。
NarraLeaf单独写了一个文本布局模块,专门做“逐字符排版”。它把每一行文本拆成字符单元,动态测量每个字的宽度和间距,并执行标点挤压规则。比如中文场景里连续两个全角问号,第二个问号的左半边会被压缩,避免视觉上出现空隙。这种细节不做用户不会骂你,做了之后台词读起来会非常顺畅。我们的领衔编剧只在第一次看到渲染效果时说了一句“终于不用在文案里手工加空格了”,这个反馈胜过任何性能数据。
字体方面我们也踩了坑。中文字体动辄10MB以上,打包到手机上是负担。解决方案是发布期做字体子集化:根据脚本里实际出现过的字符,裁剪出只包含这些字符的字体文件。第一版工具会把脚本里所有文本提取出来,按字符集生成子集字体,大小从原来的15MB压到1.2MB左右。代价是后期加新文本必须重新跑一次工具,所以这套流程被固定在CI里,每次提交脚本都会自动触发。
3.3 渲染层的常见优化手段与参数选择
在渲染设置上,比较关键的是以下三类参数:
| 参数 | 推荐区间 | 备注 |
|---|---|---|
| 纹理格式 | ASTC 4x4(移动端) / BC7(PC) | 直传PNG会让资源包爆炸 |
| 立绘淡入时间 | 0.3s~0.6s | 低于0.3s会比较生硬 |
| 阴影描边叠加 | 偏移2px以内 | 偏移过大中文会发“糊” |
| 文本刷新率 | 30fps | 文字逐字显示时足够 |
4. 开发工作流:热重载与三方协作
4.1 边改边看的编辑器,不止是“预览”
很多玩过Unity的人都被它的迭代方式惯坏了:代码改了,切回编辑器等编译一下,跑起来看效果。但Gal项目的瓶颈不在编译,而在于“台词调整带来的连锁验证”。文案改了第100行,你得翻到游戏第100行才能看到效果,效率太低了。
NarraLeaf自带一个可视化的“场记模式”编辑器。脚本文件和资源文件被持续监听,改动后引擎在内存里直接重建对应节点表,游戏画面在原场景位置热替换。策划改完台词,回车,游戏画面立刻显示新文本,不需要重新走流程。就这一个功能,让文案、美术、程序在同一个窗口里联合调试成为可能。
4.2 演出轨道参数化,消灭“写死”
演出效果在大多数引擎里都是“写死在脚本里”的。我们不想这样,于是给演出系统做了一个轨道式参数层。每条演出指令都带一组默认参数,但策划可以在编辑器里给某一段剧情单独打关键帧。比如“镜头缓慢推近”这个动作,默认是2秒线性插值,策划可以直接在轨道上把进度条拉到3.5秒并改成缓入曲线。
这套机制的实现不算复杂——演出指令编译后变成事件对象,事件对象里的参数结构在运行时可以被调整并重新插值。受益最大的是美术:她们在给立绘做呼吸动效时,不再求程序改数值,而是直接在编辑器里把呼吸幅度从8改成12,肉眼看到效果后保存。团队大概在实装这个功能一个月后,工作效率明显提升了一个档位,因为沟通链条里少了一环“程序调完你再看”。
4.3 三方协作的标准流程
我们团队最终跑顺的流程是:文案用NarraLeaf脚本写剧情,编译成节点表;美术把背景、立绘、特效资源按规定命名,拖进资源目录;程序只负责引擎稳定性和新增效果模块。三方通过一个共享的“演出批次文件”沟通,不改脚本主体。代码示例不在这里展开,但核心思想是确保脚本层永远只关心“发生了什么”,而不关心“引擎怎么实现这个发生”。
5. 打包、跨平台与发布避坑
5.1 一次性打通PC、Android与Web的发布链路
Gal游戏的主要平台在PC和移动端,但Web版用作宣传demo也很常见。NarraLeaf在架构上把平台抽象成渲染后端+输入后端,这样同一套资源包可以在三个平台上直接发布。PC端我们使用独立应用程序,移动端则严格控制纹理内存,Web端通过WebAssembly加载。
打包时最容易出问题的不是代码,而是资源路径混乱。Windows平台路径大小写不敏感,但Android文件系统是大小写敏感的,PC上写Texture/png/a.PNG运行正常,手机端直接找不到文件。我们后来在CI脚本里增加了一个自动扫描工具,凡文件名与引用路径大小写不一致就会直接报错阻断打包,这个检查已经拦下至少五次“本地没事,线上崩”的经典事故。
5.2 资源管理与增量更新方案
Gal游戏动辄几个GB的主要原因就是全量打包资源。NarraLeaf采用“首包+增量包”策略:首包只包含前20%的剧情所需资源,剩余资源放在下载目录,根据剧情进度预下载。这个策略对移动端尤其有意义,玩家进入游戏只需下载数百MB,而不是等1.5GB下完。
增量包的校验我们用MD5做文件名映射:版本号+资源哈希值构成资源名,避免更新时出现千奇百怪的覆盖冲突。这个方案不新,但在Gal引擎里做得并不多。如果读者打算自己实现,我这里真心建议先做增量校验再做加密,很多团队把精力花在加密上,结果玩家换个手机就下载失败,得不偿失。
6. 从遗存问题里挖出的小型故障排查表
6.1 使用符场景排除实录
把所有踩过的坑列出来太长,这里挑几个有代表性的写:
| 问题 | 现象 | 根因 | 解决办法 |
|---|---|---|---|
| 文本乱码 | 部分标点显示成方块 | 脚本文件编码不统一 | 编译时强制UTF-8,并拒绝带BOM的旧文件 |
| 手机卡顿 | 剧情播放到雨夜场景掉帧 | 一次性上传了50MB以上的粒子纹理 | 粒子贴图压缩到TGA后自动转ASTC,并限制同屏粒子上限 |
| 存档失败 | 在某个分支存档后读档回到开头 | 节点ID生成时依赖脚本行号,改行后ID漂移 | ID改由文件路径+标签生成 |
| 字体错行 | 繁体中文换行后行高跳动 | 使用了系统字体回退逻辑 | 自定义fallback字体链管理并统一行高 |
| 立绘闪烁 | 半透明立绘边缘出现白边 | DXT5压缩导致alpha边缘污染 | 切换为BC7,并保留原始alpha通道 |
这类问题规律性很强,出现一次修掉之后,基本整个开发周期不会再犯。所以我在团队里定了个规矩:每一类问题修完后必须补充到故障排查表里,后期新人上手也不会被同一个坑绊倒第三次。
6.2 一个小建议
排查渲染相关问题时,不要一上来就怀疑引擎Bug。我见过最多事与愿违的排查案例,最后都落在纹理格式或UV坐标计算上。正确做法是先打开调试HUD面板,看绘制批次和纹理大小,再对照资源检查表排除,通常花不了十分钟就能定位到病根。
比如一个“文本发虚”的问题,排查链是:字体文件是否被预乘Alpha,描边宽度是否大于字体尺寸的十分之一,字体子集化时是否丢失了某些字形。这三层检查完,基本就抓住问题了。所以做Gal引擎,渲染底层经验往往比花哨功能更救命。
结尾:我的个人体会
做NarraLeaf最大的体会不是“自研比现成的强”,而是做引擎的过程逼迫你把一个游戏拆成数据、表现、逻辑三层,想清楚每一句台词在项目里到底是怎么流转的。用了两年,再回头看我们最初在Ren'Py上写的原型,能明显感觉到那种“黏糊糊地堆状态”和“清爽地推数据”的差距。如果最后要我说一句给同行的建议,我想说:Gal引擎没有银弹,但用数据结构把你期望的叙事模型表达清楚,一定比什么都从剧本里现写现算来得稳。后续我们计划把NarraLeaf的节点编译器和热重载协议整理成开放文档,等这些材料打磨好再放出来,希望能给正在做叙事引擎的人一些实际帮助。