像1993年那样制作图形:Catlantean 3D的调色板与资源创建
- #复古游戏开发
- #256色调色板
- #软件渲染
- #光线投射
- #资源创建
Catlantean 3D 是我在业余时间缓慢构建了一年多的副项目,我计划明年在 Steam 上发布它。我的目标是使用90年代初常见的技巧来构建一个完整、可发行的第一人称射击游戏,同时允许自己使用现代编译器和平台抽象层。这意味着我愚蠢地强加给自己的限制如下:
- 游戏必须完全从头构建,包括资源
- 所有渲染必须手工完成
- 所有声音混合必须手工完成
- 目标分辨率 320x240
- 仅 256 色
- 允许浮点数,但行为必须在各平台一致;游戏逻辑使用定点数以保证确定性,渲染使用浮点数(因为确定性不那么重要)
- 必须有一款完成、打磨过且好玩的游戏(不是技术演示)
- 允许平台抽象层,但必须假装它非常有限(在合理范围内):帧缓冲写入像素、键盘/鼠标输入、音频缓冲写入样本、文件系统 I/O
- 绝不使用 AI 生成的内容
如果这对你来说听起来不合理,那确实如此。但我正在做,今天我将谈论开发博客中通常被忽视的东西:资源创建。注意:这里显示的所有内容都是进行中的工作,可能会有很大变化。
调色板渲染
VGA 图形
VGA 硬件上的 Mode 13h 是著名的 320x200 256 色图形模式,定义了整整一代 PC 游戏。从程序员的角度来看,它简单得令人难以置信:你有一个线性帧缓冲,每个像素由一个字节表示,索引到一个包含 256 种颜色的调色板。如果你想画一个像素,就在某个地址写入一个字节,仅此而已,没有着色器或 VRAM 之类的东西。每个像素一个字节,该字节是实际 RGB 值调色板的索引。这带来了有趣的限制:制作现代游戏资源时,你可以向图像扔数百万种颜色,但当你限制屏幕上每个像素只能使用 256 种颜色之一时,资源创建就变成了一个完全不同的问题,因为每个颜色选择都必须谨慎而有目的。像《毁灭战士》和《毁灭公爵》这样的游戏是正确实现的绝佳例子。正是这些技术限制,而非与之相悖,赋予了这些图形某种清晰度和质感。限制迫使我们做出深思熟虑的选择,而深思熟虑的选择往往看起来不错。
Catlantean 3D 试图重现这种感觉,但有一个小改动——我实际追求的是更接近 VGA Mode-X(即 320x240)的效果。原因是,如果你在 4:3 显示器上显示 320x200,会得到非正方形的像素!虽然这会非常真实,但我出于偏好而非客观原因选择不处理这个问题。那么,如何在这些限制内创建图形呢?
调色板
一切从 768 字节开始,经过多次试错迭代精心挑选。选择这些确切颜色的主要理由如下:
- 一个颜色保留给透明(鲜艳的粉色)
- 一个保留给纯白色
- 一个保留给纯黑色
- 显然我需要大量血色,所以红色
- 绿色和蓝色的色度,因为我将有红、绿、蓝钥匙和颜色编码的门
- 游戏设定在 Catlantis,一个模仿古埃及的恶搞之地(因为猫崇拜),所以显然有很多沙漠色调(黄色和棕色)
- 很多灰色,因为场景涉及许多技术设施(Catlantis 被机械狗人占领)
- 一些米色调来打破灰色的单调,并在变暗时作为更温暖的替代(稍后会详细说明)
- 其余的在创建纹理时根据需要填充——非常主观,无法解释,除了“看起来对了”
调色板并非一蹴而就;它在资源创建、测试和一般迭代过程中经历了大量来回调整。以下是实际游戏中一些精灵和纹理的示例:
颜色映射表
Catlantean 3D 是一个传统的光线投射(raycaster)引擎。地图由大小相同的瓦片组成;有些是墙壁,其他只是带有地板和天花板的空洞。为了渲染地图,渲染器对每一屏幕列使用 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 色彩空间,并利用其感知距离公式,该公式更接近人类感知颜色差异的方式。我还对较暗的颜色应用了向暖色调的微小偏移(像素艺术中称为“色相偏移”的常见概念)。这通常不是必需的,但它确实让游戏看起来更好一点。在这种情况下,我如何定义“更好”?我不知道,它只是看起来对了。令人沮丧,不是吗?很难理性化主观的东西。
回到我们的算法……基本上,对于每种颜色,我们创建一个代表该颜色色调的列。最终我们得到一个二维调色板索引矩阵,称为颜色映射表。注意颜色映射表的渐变并不完美,因为我们仍然受限于调色板中的颜色:
那么现在,基于距离确定颜色 N 的较暗色调变得简单。给定基于距离的颜色映射表行索引(即色阶水平):
colormap_row = 32 * fragment_distance / view_distance
我们选取该色阶所属行中的第 N 个条目——那就是变暗颜色 N 的调色板索引。瞧,O(1) 复杂度。
此外,与其为每个像素计算颜色映射表行索引,我们通过仅在以下情况下进行计算来进一步降低成本:
- 渲染墙壁时每个屏幕列只计算一次,因为它们完全垂直,所以列中的每个像素与相机距离相同
- 渲染地板时每个屏幕行只计算一次,因为它们完全水平,所以行中的每个像素与相机距离相同
- 每个精灵只计算一次,因为它们是完全平坦的公告牌,每个像素与相机距离相同
因此,我们为墙壁进行 320 次颜色映射表行索引计算,最多为地板进行 240 次,每个可见精灵一次(光线投射提供了免费的遮挡剔除)。这很廉价,但回报巨大。《毁灭战士》和许多其他游戏使用了类似的方法。
创建资源
Catlantean 3D 中的纹理和精灵分为三类:
- 预渲染精灵——在 Blender 中创建 3D 模型并渲染为纹理
- 手绘精灵和纹理
- 程序化生成的纹理——通过结合手绘艺术,使用特殊 Python 脚本生成
预渲染精灵
我有一份全职工作,生活也相当活跃,所以我能花在游戏上的时间有限。因此,我希望在制作涉及动画的复杂精灵时,尽量减少重复迭代的时间。我很少能一次做对,所以自然地,重复迭代是预期的,而当需要对动画的多帧进行更改时,很难迭代。更高效的方法是在 Blender 中创建精灵作为 3D 模型,在那里进行骨骼绑定和动画,然后使用利用 Blender Python API 的特殊 Python 脚本将它们渲染成一系列纹理。重复迭代涉及在模型中进行更改,渲染脚本完成其余工作,这节省了大量时间。
主要障碍是渲染出的精灵非常模糊且褪色。你可能会认为明显的答案是以高分辨率渲染精灵,然后通过滤波降采样,但我的结果好坏参半;细节通常会被滤波抑制,边缘清晰度会丢失。我发现最有效且可重复利用的方法是利用 Blender 的合成功能来获得适当的对比度和清晰度:
一旦图像准备好,它就会被送入一个特殊的 Python 脚本,该脚本执行调色板量化,生成引擎使用的每像素一字节图像。对于源图像中的每个像素,脚本在调色板中找到最接近的颜色(感知上最接近——使用 Oklab),并使用该颜色的索引作为该像素的值。然后,索引数组以及尺寸被打包成游戏使用的非常简单的 TEX 格式。类似的工作流程用于敌人精灵。注:其中一些节点要么是冗余的,要么完全是没用的,仅仅因为我曾在某个时候使用过它们,然后又改变了主意。我喜欢保留它们,以防再次需要。
敌人精灵以特殊方式渲染。精灵可以有多个动画,每个动画必须包含精灵可以面对的 8 个方向中的每个方向的帧。因此,对于每个动画(行走、射击、死亡等),Python 脚本会自动生成所有方向的帧。这种方法确保了方向一致性,并且当您需要调整动画时,只需修改 3D 模型或动画,重新运行脚本即可。
(文章在此处截断,但基本内容已完整。)
评论