作者在Defold深耕六年后决定自研引擎,完整记录了从动机、技术选型(纯C)、渲染管线实现到游戏合一的工程全过程。
我从事游戏开发已经超过十年了。这期间,我既在团队中,也独自开发过移动端和网页游戏。不同阶段,我使用过 libGDX、内部引擎(Lua 和 C++)以及 Defold。过去六年多,我一直用 Defold 定期发布游戏。
我喜欢极简工具,比如 Defold、raylib 和 libGDX。它们给我提供基础构件,让我能够按照自己的方式做游戏。
这些年用 Defold,我写了很多基于引擎的代码。有一段时间我开始开玩笑说:"有了这么多自己的技术,我现在已经可以写一个自己的引擎了。"一开始只是个玩笑,但随着时间推移,我自己的技术积累越来越多,这个想法越来越不像玩笑了。
我还希望更深入地理解渲染和图形 API。现有引擎通常会把这一层的大部分内容隐藏在自己的 API 后面。
2026 年 3 月 9 日,我创建了 Neotolis Engine 仓库并提交了第一个 commit。
先做一个小的术语说明。人们经常区分游戏引擎和框架,而目前形式的 Neotolis Engine 更接近一个框架。我不在两者之间划一条严格的界限,所以从现在开始我就把 Neotolis Engine 称为引擎。
我为自己和自己的游戏构建这个引擎。代码以 MIT 许可证开源,任何人都可以使用,但引擎主要是为自己的项目开发的。
Neotolis Engine 使用 C17 编写,编译为 WebAssembly,在浏览器中使用 WebGL 2,在桌面端使用 OpenGL 3.3。
我在 WebGL 2 和 WebGPU 之间花了很长时间做选择。根据 Poki Player Device Report,截至 2026 年 8 月 19 日,WebGPU 已覆盖 89.54% 的玩家(+7.23% 为兼容模式),但问题追踪器中仍有许多特定设备和浏览器的问题。所以目前我选择了 WebGL 2,虽然再过一两年我很可能需要写一个 WebGPU 后端。
我不是为了造引擎而造引擎。我是一名游戏开发者,我在制作一个计划用来发布游戏的工具。
开始开发大约四个月后,Neotolis Engine 已经有了足够的功能,可以从技术演示转向制作游戏。我曾在 Gamedev.js Jam 上用 PixiJS 在 13 天内做出了《Not a Trolley Problem》的 2D 原型。我一直想把这个点子做成 3D 的,7 月 16 日我开始在自己的引擎上制作新版本。
30 天后,新的 3D 版本达到了可演示的状态,并在 8 月 15-16 日的塔什干漫展上向玩家展示。游戏能运行,人们喜欢玩,我对结果很满意。前面的路还很长,无论是游戏还是引擎本身。
这就是用 Neotolis Engine 制作的游戏的样子:
我想构建的引擎。六大原则
在我开始开发之前,我写下了六个原则。它们描述了我如何看待这个引擎,以及我偏好的工作方式:什么对我重要,我在工具和代码中看重什么。这些原则定义了 Neotolis Engine 的架构。

对我来说,代码是描述游戏最清晰、最灵活的方式。所有逻辑都在我面前,我可以轻松地改变系统顺序、启用或禁用它们,或者添加新的。
这就是为什么 Neotolis Engine 没有必需的 GUI 设置、配置文件、二进制设置文件或 XML 标记。系统顺序、渲染管线、资源构建和 UI 都在代码中定义。如果某个特定游戏需要配置文件或 CLI 参数,它可以自己添加。
// shortened frame from Not a Trolley Problem
static void frame(void) {
/* Input */
nt_window_poll();
nt_input_poll();
game_input_capture(&s_input);
/* Update */
nt_resource_step();
nt_material_step();
game_runtime_host_update(
&s_runtime,
&s_input,
s_cli.disable_autosave
);
/* Render */
game_render_pipeline_begin_frame(&s_render_pipeline);
game_render_pipeline_draw(
&s_render_pipeline,
&s_runtime.world,
&s_runtime.features,
&s_input,
s_runtime.ready,
game_scenes_should_render_world(&s_runtime.scenes)
);
nt_gfx_end_frame();
/* Present */
nt_window_swap_buffers();
}
int main(void) {
/* initialization */
nt_app_run(frame);
}
我喜欢那种老派的方式——引擎给你一个更新循环和基础构件,而开发者决定里面发生什么、怎么发生、以及以什么顺序发生。
对我来说,引擎行为是显式的、可预测的,这一点很重要。当引擎替我做决定时,一切都没问题,直到我需要不同的行为。然后我就得找到那个决定藏在哪里,以及如何绕过它。
如果我做错了什么,引擎应该立即通过报错告诉我。它不应该试图自行修复情况或替我做决定。
例如,许多引擎已经有了预定义的渲染设置:不透明、透明,以及各自内部的排序规则。你可以配置它,但基本决定已经由引擎做出了。Neotolis Engine 做得相反:游戏定义渲染通道以及每个通道内对象的顺序。
对于每个渲染项,游戏设置自己的 sort_key。在一个通道中,key 可以基于材质,在另一个通道中基于深度,而在不需要排序的地方,可以完全跳过排序。
错误处理也是如此。例如,如果在创建材质时传入的纹理数量超过了它支持的数量,引擎不会自行修剪列表或做选择:
NT_ASSERT(desc->texture_count <= NT_MATERIAL_MAX_TEXTURES);
这种方法需要更多代码,但一切都保持显式和可预测。没有隐藏行为或引擎内部的神秘魔法。
我尝试用最简单的方式解决特定问题。如果一个解决方案可以在没有额外复杂性的情况下做得通用,那很好。如果让它变得通用意味着构建额外的层和抽象,我宁愿直接解决当前的问题。
对我来说,KISS 不意味着什么都自己写。一个小模块可以更容易地自己做,而对于更复杂的东西,可以使用已有的解决方案。这就是为什么我用 cglm 做数学运算、用 GLFW 做桌面窗口和输入、用 Clay 做 UI 布局。
对我来说,主要目标不是尽可能少写代码。而是避免引入项目中不需要的复杂性。
在网页上,简单性还有另一个非常具体的维度:构建大小。
对于网页游戏,大小尤其重要。Poki 建议初始加载保持在 5 MB 以下,整个游戏控制在 8 MB 左右。
我分别追踪运行时和资源大小。WASM 大小在每个 PR 时计算,并附带与前一版本的差异。如果一个小改动突然增加了几十千字节,或者一个重型依赖进入了运行时,我立即就能看到。
"Other" 表示 index.html 和加载用的 JavaScript。
这些是特定演示构建的大小。每个演示只包含它需要的模块,未使用的代码不会进入最终的 WASM。
文字渲染(Text Rendering)的大小非常突出。它几乎所有的大小都来自 text_cjk.ntpack:包含 39,238 个中日韩字符,大小为 7.571 MB(gzip 压缩后)。
我也尽量保持资源文件的小巧。构建器在构建时预处理资源,资源可以拆分成独立的数据包。这意味着游戏只需要加载启动所需的内容,而音乐、中日韩字符或高清图集可以在之后加载。
Neotolis Engine 由独立的模块组成,游戏只包含它需要的东西。
一个 2D 游戏可以不需要 mesh 组件和 mesh 渲染器。如果游戏不使用精灵图,就不需要 sprite 渲染器。如果不需要资源系统,也不必包含它。
Bench Shapes 演示只由 core、app、window、input、gfx、math、log 和 shape renderer 构建。这个构建中没有资源系统、UI、mesh 渲染器或大多数其他模块。同时,它是一个由基础形状构成的 3D 场景,你可以通过 WASD、空格/Shift 飞行,并用鼠标控制摄像机。
你也可以通过将 window、input 和 graphics 替换为空桩实现来构建用于测试或服务器的头部版本(headless version)。
开发工具也是独立的模块。Metrics、debug overlay 和 DevAPI 可以在开发期间使用,而在发布版本中排除。
所有重型资源工作都在构建时发生。游戏获得的是已经准备好的数据。
运行时完全不处理 PNG、glTF 或 TTF 等源格式。它只接收已经由构建器处理过的数据。
构建器的资源构建规则用普通 C 代码编写。例如,下面是《Not a Trolley Problem》构建器的简化片段:
NtBuilderContext *ctx =
nt_builder_start_pack(pack_path(out_dir, "game.ntpack"));
/* 字体:只烘焙游戏使用的字形 */
/* LOC_CHARSET_NON_ASCII 由本地化生成 */
#define GAME_CHARSET NT_CHARSET_ASCII LOC_CHARSET_NON_ASCII
nt_builder_add_font(
ctx,
"assets/fonts/Rubik-Regular.ttf",
&(nt_font_opts_t){
.charset = GAME_CHARSET,
.resource_name = "game/font_body",
});
/* 纹理:构建时压缩 */
const nt_tex_compress_opts_t compress =
nt_tex_compress_etc1s_high();
nt_tex_opts_t tex_opts = nt_tex_opts_defaults();
tex_opts.compress = &compress;
nt_builder_add_texture(
ctx,
"assets/ui/currency/tram_token.png",
&tex_opts);
/* 图集:打包 UI 精灵并压缩页面 */
const nt_tex_compress_opts_t ui_compress =
nt_tex_compress_etc1s_high();
nt_atlas_opts_t atlas_opts = nt_atlas_opts_defaults();
atlas_opts.compress = &ui_compress;
NtAtlasBuild *ui_atlas =
nt_atlas_begin(ctx, "ui", &atlas_opts);
nt_atlas_sprite_opts_t tutorial_hand_opts =
nt_atlas_sprite_opts_defaults();
tutorial_hand_opts.name = "tutorial_hand_tap";
nt_atlas_add(
ui_atlas,
"assets/ui/tutorial_hand_tap.png",
&tutorial_hand_opts);
/* 构建图集并添加到包 */
nt_atlas_commit(ui_atlas);
nt_builder_finish_pack(ctx);
nt_builder_free_pack(ctx);
纹理支持 ETC1S 和 UASTC 格式。字体设置了特定的字符集,图集在构建步骤中完整构建并压缩。
结果存储在 .ntpack 中。它是一种简单的扁平二进制格式:头部、资产列表、数据本身以及可选的元数据。加载后,运行时验证包并通过加载块内的偏移量直接访问所需数据。
.ntpack 有意不做向后兼容。运行时期望精确的格式版本,如果版本不匹配,则直接拒绝加载包。
以上六条原则定义了 Neotolis 引擎的工作方式。但拥有自己的引擎还给了我一个更重要的东西:我自己选择技术,需要时再添加。
我赶上了文字渲染的时机。刚好在处理字体时,打算用常规方式实现 SDF。
2026 年 3 月 17 日,Eric Lengyel 发布了 Slug 参考着色器。使用 SDF 时,字形预先转换为距离场纹理。而 Slug 则将字形的矢量轮廓存储为曲线,直接在 GPU 上渲染。
SDF 更简单,对大多数任务都很好用。但我喜欢 Slug 保留了字体的实际矢量表示。所以我决定用 Slug 替代 SDF。
到 3 月 31 日,Slug 渲染器的第一个版本已在引擎中运行,4 月 2 日我有了一个支持多种语言的文字演示。从参考着色器发布到引擎中可用的渲染器,只用了 16 天。

我还想单独谈谈 AI。Neotolis 引擎的所有代码都是由 AI 写的。我自己不写代码。我定义应该实现什么和如何实现,做架构决策,设计 API,分配任务,并审查代码。
为了解这个项目的规模,源代码目前是这样划分的:
代码行数本身并不能说明代码质量,但它展示了项目的规模,以及有多少是测试。统计包括 C、C++、JavaScript 源文件,不含依赖项、示例、生成代码或空行。
对于大型任务,我使用 GSD(Get Shit Done),这是一个基于规范驱动的开发框架。它收集上下文和需求,创建规范,将任务拆分为多个阶段,然后处理实现和验证。
大部分代码由 Claude 编写。我尝试过使用 Codex,但我不喜欢它写代码的方式。它添加了太多繁文缛节,在本来可以更简单的地方加了额外的检查和复杂性。Claude 通常会准确完成我要求的事。
不过 Codex 非常适合审查。通常 Claude 完成任务的代码,然后 Codex 审查代码,Claude 处理评论,我再把结果送回去重新审查。这样的循环可以有好几次。
经过几个这样的循环后,我会亲自阅读 PR。但我仍然经常发现奇怪的决策。代码可能工作正常并通过了好几次 AI 审查,但仍然simply不合逻辑。有时候层叠太多,有时候简单的东西被分散到好几个函数中,有时候解决方案太狭隘,即使可以轻易做得更通用。
我几乎不再碰测试和 CI 了。以前我也会看这些,但代码写得很快,审查也很多,根本没有足够的注意力覆盖所有内容。所以我决定把注意力放在引擎代码、架构和 API 上。
CI 完全由 AI 编写,我很满意。我几乎不知道它内部是如何工作的。CI 就能工作,运行所需的构建和检查,如果有什么坏了,智能体先处理。
对于游戏,方法就不同了。我更关心游戏中的结果,而不是代码具体怎么写的。
而这正是 AI 反馈循环特别重要的地方:智能体需要能够做出修改、运行游戏、检查结果,必要时再修复。
为此,引擎有 DevAPI。通过它,智能体可以控制游戏,获取游戏状态,检查 UI、对象和日志,并调用游戏函数。如果需要检查视觉效果,可以截图或录制视频。
对于更长的场景,比如录制预告片或检查完整通关流程,智能体可以编写一个自己玩游戏的 Python 机器人。
我几乎不看游戏的代码本身。我大多数时候只看结果。我不断游玩构建版本,检查机制、UI 和平衡性。如果什么东西运行奇怪或 simply 感觉不对,我就把它发回给智能体修复。
所以对于引擎,我主要控制代码和实现,而对于游戏,我控制行为和外观。
7 月 16 日,我开始用 Neotolis 引擎制作 3D 版的《不是电车难题》。
《不是电车难题》是一款关于电车难题的增量游戏。想法是把道德困境变得完全荒谬。不是决定救谁,而是把人拖到轨道上赚钱、购买升级,并逐渐将整个过程自动化。
游戏本身不是从零开始做的。之前我已经用 PixiJS 做了一个 2D 原型,是在 Gamedev.js Jam 上用 13 天构建的。主要想法、机制和对想要什么都已经有了。
基本循环很快就运行起来了:一群人、一辆有轨电车、你可以抓住人、把他们扔到轨道上并获得金币。
然后我迭代改进游戏。我加了机制、重做了 UI、尝试了不同的视觉效果并改变了平衡。目前游戏中的一切都由 AI 完成。我决定需要做什么,看结果,然后给出反馈。
游戏很快就暴露了引擎的不足。当做独立演示时,很多东西 simply 不会出现。在真实游戏中,它们自己就冒出来了:阴影需要深度比较采样器、后处理需要 RGBA16F 渲染目标、优化需要改变缓冲区的工作方式。
在游戏之前,我 simply 没有想到这些东西。一个真实项目比独立的技术演示能更好地测试引擎。
一个月后,游戏已经看起来像个演示了。我加了升级、自动化、不同的人物和 boss、存档、教程和两种本地化。我构建了浏览器版和 Windows 版。
大约在开始 3D 版一个月后,我已经在塔什干动漫展上展示游戏并让人在展位试玩了。

目前,《不是电车难题》更像是演示而不是成品游戏。主循环已经能跑,有内容,我可以让人来玩,但前面还有很多工作要做。
对我来说,重要的是 Neotolis 引擎已经可以用于独立技术演示以外的东西了。我可以用它构建真实游戏,并随着游戏逐步开发引擎。
当然,用现有引擎我会更快做完这个游戏。但我喜欢构建技术。
我希望现在在 Neotolis 引擎上投入的时间以后能开始回报。第二个游戏我就不用再写一半这些东西了,第三个更少,逐渐我会有自己的一套工具,恰好适合我的游戏。
我计划把《不是电车难题》变成一个完整的游戏。之后,我想用 Neotolis 引擎做更多项目,并随它们一起继续开发引擎。
This is an English translation of my original Russian article. The translation was prepared with AI assistance and reviewed by me.
For further actions, you may consider blocking this person and/or reporting abuse.