引言:回到 1993 年做图形
个人网站和博客。
Catlantean 3D 是我在过去一年多闲暇时间里慢慢打磨的一个副业项目,我打算明年在 Steam 上发布。
我的目标是使用 90 年代初常见的图形技术,构建一款完整、可发布的第一人称射击游戏——同时允许自己奢侈地使用现代编译器和平台抽象层。
这实际上意味着,我愚蠢地给自己施加了以下约束:
如果你觉得这听起来不太合理,那是因为它确实不合理。
但我还是这样做了。今天我要聊一个在开发博客中经常被忽略的话题——资源创建。
注:这里展示的一切都是进行中的工作,可能会有很大变化。
VGA Mode 13h:定义一代 PC 游戏
VGA 硬件上的 Mode 13h 是著名的 320×200 256 色图形模式,它定义了一代 PC 游戏。从程序员的角度来看,它简单得令人惊叹:你有一个线性帧缓冲,每个像素用一个字节表示,索引到一个包含 256 种颜色的调色板。
如果你想画一个像素,你就在指定地址写一个字节——就这样。没有着色器、没有 VRAM,什么都没有。
每个像素一个字节,这个字节是调色板的索引,而调色板中存储了实际渲染到屏幕的 RGB 值。这带来了一些有趣的限制:制作现代游戏资源时,你可以给图像使用数百万种颜色,但当你的限制是屏幕上每个像素只能从 256 种颜色中取一时,资源创建就变成了一个截然不同的问题——因为每个颜色选择都必须谨慎且深思熟虑。
《毁灭战士》和《毁灭公爵》等游戏就是这方面做得好的例子。这些图形具有某种锐利感和清晰度,正是因为这些技术限制而出现的,而不是尽管有限制。限制迫使你做出深思熟虑的选择,而深思熟虑的选择往往看起来更好。
Catlantean 3D 试图重现这种感受,但有一个不同之处——我实际上追求的是更接近 VGA Mode-X 的东西,即 320×240。原因是,如果你在 4:3 显示器上显示 320×200,你会得到非正方形的像素!虽然这最符合原始体验,但我选择不处理这个问题——纯粹是个人偏好,而非客观原因。
那么,如何在这些限制下创建图形呢?
调色板:从 768 字节开始
一切都始于 768 个字节,经过无数次试错精心挑选而来。
选择这些精确颜色的主要逻辑如下:
调色板并不是一次性成型的,它涉及大量的来回调整——在资源创建过程中测试、反馈、再迭代。
以下是一些来自游戏实际中的精灵和纹理示例:
光线投射引擎
Catlantean 3D 是一个传统的光线投射引擎。地图由大小完全相同的瓦片组成,有些是墙壁,有些只是带有地板和天花板的空白区域。为了渲染地图,渲染器对屏幕的每一列使用 DDA 算法,遍历瓦片地图并确定它在哪里击中地图几何体——基于此,在屏幕上渲染带有适当纹理的墙壁列,从相应的坐标采样。地板和天花板随后作为水平扫描线渲染,填满屏幕的其余部分。
光线投射已经被其他博客和网站讲到烂了,所以我不打算覆盖所有内容,但我想谈谈我认为最被忽视的一个方面:光照。
如果我们只用调色板渲染游戏世界而不做任何特效,会得到这样的效果——相当平淡乏味:
但我们想要的是这样的效果。注意,光照随着几何体离玩家越远而逐渐减弱,以及地图瓦片的一侧比另一侧略微暗一些——这给人一种深度感。
用现代硬件加速渲染器,这在着色器中是轻而易举的事——根据顶点距离,将颜色向量乘以一个浮点因子,就能得到变暗后的颜色向量。
但如何在调色板渲染器中实现类似的效果?它没有颜色的概念,只有调色板的索引。所以如果我们想找到某个颜色的更暗色调,就需要遍历整个调色板,找到符合"更暗"条件的颜色。这显然行不通——我们不能为屏幕上渲染的每一个像素都遍历整个调色板,那样太慢了。
颜色映射表:预处理的力量
我们能做的是预处理,让运行时基于距离快速查找颜色。
如果我们把调色板排列成一行……
然后我们选择色阶数量(在我的例子中选 32),这意味着每个颜色需要 31 个更暗的变体,这些变体全部来自调色板。我们知道每个颜色的 RGB 值,因此可以从这些值和色阶索引确定该色阶最接近的目标颜色:
// 第一个色阶索引(0)是原始颜色
float darkening_factor = (32 - shade_index) / 32.0f;
target_darker_color.r = current_color.r * darkening_factor;
target_darker_color.g = current_color.g * darkening_factor;
target_darker_color.b = current_color.b * darkening_factor;
但目标颜色可能在调色板中不存在。所以我们需要遍历调色板,找到最接近的颜色。
实际上,"接近"的定义在开发过程中发生了变化——起初我使用欧几里得距离作为度量,但问题是几乎所有颜色都有趋向灰色的倾向——纯粹是由于数学原因。一些老游戏确实使用了欧几里得距离,但对我来说这看起来不太好。我无法准确解释为什么,但很多更暗的色调显得有点冷冰冰的,没有生气。因此,我转而将颜色转换到 Oklab 颜色空间,利用它的感知距离公式,这更接近人类感知颜色差异的方式。我还对越暗的颜色做了朝向暖色调的小偏移(像素艺术中称为"色调偏移"的常见概念)。这通常不是必须的,但它确实让游戏看起来好那么一点。
我如何定义"更好"?我不知道,就是看起来对了。很令人沮丧,对吧?主观的东西很难理性化。
回到我们的算法……
基本上,对每种颜色,我们创建一个列来代表该颜色的各阶深浅色。最终我们得到一个二维的调色板索引矩阵,称为颜色映射表(colormap)。注意颜色映射表的渐变并不完美,因为我们仍然受限于调色板中的颜色:
这样,基于距离确定颜色 N 的更暗色调就变得非常简单了。
给定基于距离的颜色映射表行索引(即色阶级别):
colormap_row = 32 * fragment_distance / view_distance
我们挑选该行中第 N 个条目——那就是变暗后的颜色 N 的调色板索引。
于是,O(1) 复杂度,搞定。
此外,我们不必为每个像素计算颜色映射表行索引,而是进一步降低成本:
我们只需对墙壁做 320 次行索引计算,对地板最多 240 次,每个可见精灵一次(光线投射提供了免费的遮挡剔除)。这非常廉价,回报却巨大。
《毁灭战士》和许多其他游戏使用了类似的方法。
资源流水线:Blender 3D 模型 → 精灵纹理
Catlantean 3D 中的纹理和精灵分为三类:
我有一份全职工作,日常生活也相当活跃,所以花在游戏开发上的时间有限。因此,我想尽量减少制作涉及动画的复杂精灵时重复迭代的时间。我很少能在第一次尝试时就做对,所以迭代是必然的——而当你需要对动画的许多帧进行修改时,迭代就变得很困难。
更高效的方法是在 Blender 中以 3D 模型的形式创建精灵,进行骨骼绑定和动画制作,然后利用 Blender 的 Python API 通过专门的脚本将它们渲染成一系列纹理。后续迭代时只需要修改模型,渲染脚本负责剩下的工作——这节省了大量时间。
主要障碍是渲染出来的精灵非常模糊且褪色。
有人可能会想,明显的解决方案是以高分辨率渲染精灵,然后用滤镜降采样。但我的效果参差不齐——细节往往被滤镜压制,边缘清晰度也会丢失。我发现最有效且可复用的方法是利用 Blender 的合成功能来获得恰到好处的对比度和清晰度:
图像准备好后,通过一个专门的 Python 脚本进行调色板量化,生成引擎使用的每像素一字节的图像。对于源图像中的每个像素,脚本在调色板中找到最接近的颜色(感知最接近——使用 Oklab),并使用该颜色的索引。索引数组与尺寸信息一起被打包成游戏使用的非常简单的 TEX 格式。
类似的流水线用于敌人精灵。注:有些节点要么是多余的,要么是完全没有用的——只是因为我曾经在某个时候使用过它们,后来改变了主意。我喜欢留着它们,以防将来再次需要。
敌人精灵以一种特殊方式渲染。精灵可以有多个动画,每个动画必须有 8 个方向的帧序列。所以,对每个动画(行走、射击、死亡等),使用 Blender API 的 Python 脚本旋转精灵,渲染动画的所有帧,再旋转精灵,继续渲染。精灵按照特定命名约定保存,表示精灵名、动作名、方向和帧索引:
这种方法的一个优点是,我不需要把渲染出来的精灵保存在仓库里——它们实际上被 .gitignore 了。每当我换到另一台电脑上工作,只需运行编译脚本,渲染每个模型并生成精灵。在 RTX 3070 上,这相当快——约 15 个模型大约 10 秒就能跑完。
手绘的重要性
在开发早期,我创建了一个模糊的猫形头部,用我的猫 Vilko 的纹理做贴图,用作状态栏头像。毕竟,如果 Blender 能渲染出如此生动的形象,我为什么要手绘呢?
答案很明显——它看起来很懒、很敷衍,事实也确实如此。它在传达情感方面做得并不好,也没有灵魂。当我收集关于氛围的反馈时,这通常是人们首先指出的问题。
有些事情必须手绘。我不是艺术家,但我很确定手绘的变体配上动画效果要好得多,而我永远无法在 Blender 中动画出同样的效果。由于精灵的尺寸限制,每个像素都必须经过深思熟虑,所以没有理由把这项工作留给 Blender 渲染器。
我把同样的逻辑应用到了大部分可拾取物品上——它们之前也是预渲染的,但在 Blender 合成器无法可靠产生好结果的尺度上。经过人工处理后,它们的清晰度和可辨识度大大提升了。
你可能会想,为什么不直接提高精灵分辨率?游戏光栅化器会负责缩放,对吧?
理论上可以,但结果会非常糟糕,因为像素比例不再一致。在屏幕的任何一行或一列上,你会下意识地期望像素在你靠近或远离时保持相同大小。如果像素比例在不同精灵之间变化,这种一致性就被破坏了,看起来会很别扭。这可能是很多资产拼凑游戏或低质量独立游戏看起来糟糕的最大原因之一——他们把不同比例的资源拼在一起,根本无法协调工作。
因此,Catlantean 3D 世界中的 1 个单位是 64 像素,每个精灵都相对于这个比例制作。所以如果我们想要一个四分之一世界单位高的精灵,它必须是 64/4=16 像素高。
HUD 界面:手绘与合成
HUD 及其元素几乎全部是手绘的。这包括:
例如,这个关卡结束时的计分屏幕(进行中):
HUD 的"手绘"是指每个元素都经过深思熟虑、由我手动放置,但我大量使用了 Affinity Photo 的图层效果和合成功能,而不是从零开始画所有东西。这些效果包括:
我通常在 Affinity Photo 中以真彩色工作。注意图层——这些元素大多只是单色矩形,上面应用了特殊效果和混合魔法。
虽然 Affinity Photo 很强大,但从它导出的图像包含一些奇怪的伪影,很可能与抗锯齿有关——我无法完全关闭它,或者说无法找到一致关闭的方法。所以它不适合像素完美的工作,这意味着我必须在 Aseprite 中再做一遍,涉及以下工作:
程序化纹理生成
有些纹理足够简单或特定,适合手绘。但 Catlantean 3D 中的许多纹理共享一个共同结构:带有磨损、污垢和表面细节变化的基础材质。手绘每种变体既乏味又不一致,所以我写了 Python 脚本来生成它们。
生成管道接受多个输入:
根据这些输入,脚本生成最终的纹理,经过调色板量化,准备好给引擎使用。调整纹理变成了调整参数而不是重新绘制像素的问题——当你是一个人做这个项目时,这大大节省了时间。
一些生成的纹理示例:
爆裂动画管道
我还开发了专门的管道来创建爆裂(gibbing)动画。爆裂敌人通常由过量伤害触发——例如近距离的霰弹枪轰击或爆炸。为了传达这种伤害的冲击力,敌人被炸成血肉模糊的碎片:
该管道由一个 Python 脚本驱动。给定精灵、调色板和一组参数,它生成一系列帧,最终作为动画进入游戏数据。
工作原理如下:
精灵被分割成多个块(chunk)。从精灵不透明主体中随机选择 K 个种子像素,每个像素被分配到离它最近的种子。每个结果单元格成为一个飞行的碎片。
块边界(与不同块相邻的像素)被标记为深度 0 的伤口。BFS 算法向内扩散,分配递增的深度值。在渲染时,靠近边界的像素的颜色向血液颜色混合——从游戏调色板派生的渐变中采样。越深入块的内部,原始精灵颜色保留得越多。
从调色板中渐变的选取是参数化的,所以我可以为某些敌人使用绿色或蓝色的"血液"。
每个块获得一个质心、一个从精灵中心向外指向的速度方向(带随机扩散)、旋转速度、重力和阻力。然后仿真使用这几个参数开始运行。没有碰撞检测——块碰到地面就停止。虽然简陋,但已经够好了。
块的数量、爆炸力度、重力、阻力、扩散和伤口深度都可通参数调节,例如:
{
"seed": 295312884,
"frames": 20,
"chunks": 48,
"explode": 3,
"gravity": 1.4,
"drag": 0.22,
"spread": 1.15,
"spin": 9,
"woundDepth": 2
}
找到一个看起来不错的随机种子需要一些试错,但这肯定比手绘这些动画快得多。同样的技术也用于可破坏的环境物体(花盆、木桶、板条箱等)。
像预渲染的动画一样,这些也不保存在仓库中,而是在仓库检出后重新生成。执行时间可以忽略不计。
粒子效果:预烘焙的能量场合成
虽然大多数粒子效果是在 Aseprite 中手绘的,但有些粒子和爆裂一样通过生成并烘焙:Python 脚本运行仿真,产生一系列 PNG 帧,然后量化为 TEX 格式。游戏没有运行时粒子系统,所有效果都是预烘焙的,以便软件光栅化器能以最快速度渲染。
"粒子"这个词在这里有点误导,因为管道实际上根本不模拟粒子。相反,每一帧通过逐像素计算径向能量场来合成,多个独立层叠加在一起:
每个像素累积的能量然后针对脚本参数指定的调色板渐变做量化。调色板中的每一行都被视为从亮到暗的渐变——纯粹是因为我在设计调色板时就是这样排列的——所以每个像素的变暗不需要任何混合或 Alpha 运算,只需要调色板索引算术。超过某个阈值后,像素被推向白色,从而给人白热核心的印象。
此外,可选地,少量火花被撒在顶部——十字形状,向外漂移并在自己的生命周期内淡出。
动画支持两种模式:一次性——像爆炸或传送闪光那样上升然后衰减;以及循环——沿圆形路径采样噪声场,使第一帧和最后一帧匹配,实现无缝循环(适用于持续循环的效果,如等离子球、能量弹等)。
自定义地图编辑器
地图编辑始于 Tiled——这是一个相当合理的工具,直到你开始有非常特殊的需求。
它缺少的是我的游戏实际需要的概念:每个格子的光照强度绘制、格子标志和属性。我最初通过滥用对象属性来解决。更麻烦的是,工作流需要一个 Python 脚本来将 Tiled 的 JSON 输出转换为引擎使用的二进制格式——这是一个纯粹为了弥补不匹配而存在的额外环节。
还有另一个问题:把这个编辑器和游戏一起发布给玩家。期望某人安装 Tiled、学习它的界面、设置转换脚本、折腾这一切只是为了制作一个地图,这太不合理了。这会扼杀掉玩家真正使用编辑器的任何可能性。
所以我自己写了一个。它原生支持光照强度绘制、格子标志、以及游戏知道的所有实体和属性类型。开发过程明显变得更加愉快,因为我不再被 Tiled 的限制所约束。当游戏发布时,玩家会得到和我使用的完全一样的编辑器。
它是即插即用的,你甚至可以直接从编辑器中启动关卡:
是的,我知道工具栏图标很难看。这正是我保留它们的原因。
编辑器使用 wxPython 构建,这对这类工具来说是一个不错的选择。它比 tkinter(我最初试过的)效果好得多——尤其是控件、事件处理、布局。用起来感觉更好,最终结果也更像是原生应用。迭代很快,围绕 MVP 模式组织保持了 UI 逻辑与地图数据的清晰分离——当你频繁修改两者时,这点很重要(我的地图格式还不算很稳定)。对于既需要作为单人开发者内部工具、又需要分发给最终用户的场景来说,它达到了一个很好的平衡。
编辑器中不是所有内容都用 Python 写的。模型部分大量依赖我的 pybast 库,它本质上是引擎内核对 Python 的绑定(通过 pybind11),包括:
这主要是为了避免在 Python 中重新实现那些我已经在 C++ 中实现的功能。引擎和它的工具形成一个小而紧密的生态系统。
发布计划
我预计在 2027 年 Q1 左右发布 Catlantean 3D。现在,我主要专注于关卡设计、增加更多敌人和武器,并在推进过程中不断完善。
定价目标在 $5-$8 之间。我打算在 GitHub 上以开源方式发布游戏源代码,但你需要购买游戏才能获得实际的数据存档(图形、关卡、音效、音乐等)——我认为这很公平。
毕竟,我需要给主角他应得的那份——等等,我知道它是女性角色,但这只是方言习惯。
开玩笑了。说真的,我认为对过程的透明是少数能真正建立持久信任的东西之一。
独立游戏在这方面的生死攸关程度是 AAA 游戏无法比拟的——因为 AAA 市场已经表明,玩家会接受任何被塞给的东西。独立游戏没有这种奢侈。受众更小,但他们更愿意关注一个项目、为它加油,并在感觉自己是过程的一部分而不是被营销对象时,把它推荐给别人。
展示你的工作是你能做的最诚实的事情。我认为人们能分辨出哪些人是真正在乎自己创造的东西的。
更多内容,敬请期待。
感谢阅读!
如果你喜欢这篇文章,欢迎在 社交媒体 上关注我。