为微型掌机打造微型3D渲染器

Hacker News Top 新闻

摘要

一位开发者分享了他们为Playdate掌机构建3D软件渲染器的经验,从raycaster基准测试开始,讨论了性能挑战以及与复古游戏机的比较。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/07/25 11:08

# 为迷你掌机打造迷你 3D 渲染器 来源:https://saffroncr.itch.io/katavatis/devlog/1534514/building-a-tiny-3d-renderer-for-a-tiny-handheld 今天我想聊聊我为 **Playdate** 编写一个 3D 软件渲染器的历程。 起初,我没有任何性能基线。我不知道我的想法有多可行,所以我从一个简单的测试开始:一个光线投射器。 多年前我曾写过一次,基于 Ken Silverman 网页(https://advsys.net/ken/klab.htm)上的代码示例,从那时起,每当我想要测试低功耗设备的 3D 处理能力和屏幕绘制性能时,它基本上就成了我的首选工具。 编写、编译和运行这类测试所需的资源不多,而且它能让你大致了解设备的处理能力。更具体地说,它能让你对 3D 渲染器最重要的几个方面进行基准测试:处理浮点和向量数学的速度有多快,内存操作的速度有多快,绘制屏幕的速度有多快,以及设置和使用帧缓冲区是否存在任何问题。 在模拟器中运行的光线投射器 最初的结果比我预想的要差。性能糟糕到让我清楚这不会是一个轻松的项目。 在 Playdate 上运行的光线投射器 需要说明的是,我的光线投射器并不是以极致性能为目标编写的。我从来不是为了在 **Playdate** 上实现一个光线投射引擎。我相当确定,只要进行足够的优化,那是可以做到的。关键是感受一下这台设备的性能到底有多强(或者有多弱)。 这个测试让我更好地了解了这台掌机的性能。很明显,它没有足够的能力来绘制像早期 3D 加速卡(如 **3dfx Voodoo**)那样级别的 3D 场景,也无法与最初的 **PlayStation** 上能做到的效果相媲美。但希望还是有的。**Playdate** 的屏幕很小,分辨率也很低,而且由于它使用的是 1 位显示器,内存方面有巨大的优势。 根据这些结果,我仍然有信心能够做出一些东西,从玩家的角度来看,感觉上接近 **3DO** 或 **Sega Saturn** 时代。 我需要澄清一下我的意思。当我说到类似于 **3DO** 或 **Saturn** 游戏时,是指屏幕上的最终视觉效果。我不是从技术角度来说的,因为这些主机的硬件与 **Playdate** 非常不同。 **3DO** 和 **Saturn** 都有用于绘制多边形(即四边形)的定制硬件,以及性能一般的 CPU。由于图形硬件应该承担大部分繁重的工作,制造商可以使用更便宜的 CPU。不利的一面是,这些机器灵活性不高。它们擅长以硬件设计的特定方式渲染图形,但当游戏需要不同的方法时就会遇到困难。这就是它们的《毁灭战士》移植版表现如此糟糕的原因之一:《毁灭战士》的渲染器并不符合这些主机绘制 3D 图形的方式。 3DO 上的《毁灭战士》 另一方面,**Playdate** 没有 3D 硬件。如果我们想要渲染 3D 图形,一切都需要在 CPU 上完成。 没有多边形排序。没有光栅化(https://en.wikipedia.org/wiki/Rasterisation)硬件。没有深度缓冲(https://en.wikipedia.org/wiki/Z-buffering)。没有对三角形进行视锥体裁剪(https://en.wikipedia.org/wiki/Clipping_(computer_graphics))的自动功能。没有纹理映射(https://en.wikipedia.org/wiki/Texture_mapping)、过滤或透视校正。什么都没有。 ## 3D 图形快速入门 在进一步讨论之前,有必要解释一下我们谈论 3D 图形时真正指的是什么。 我们生活在一个三维世界中。物体有深度、高度和宽度。它们可以离我们很远或很近,可以在我们上面或下面,可以在左边或右边。我们能够感知这三个空间维度并在其中移动。 但我们的眼睛只接收到从这个世界投射到视网膜上的平坦的二维图像。我们的大脑通过透视、运动、缩放、阴影和遮挡等线索来推断 3D 世界。 看向 3D 世界的眼睛 计算机图形学使用类似的技巧。 一个 3D 游戏并不是以真正的 3D 形式绘制世界的。屏幕是平坦的。即使我们称之为 3D 的屏幕,仍然是通过显示平坦的 2D 图像来工作的。我们所做的是处理描述 3D 场景的信息,在该空间中放置一个摄像机,然后将场景投影到一个 2D 平面上,这类似于真实的 3D 世界投影到我们眼睛的 2D 视网膜上的方式。 视锥体 光栅化器是渲染器的一部分,它负责将投影后的几何体转换为像素,也就是我们用来在计算机屏幕上绘图的元素。它负责处理如何绘制纹理、处理重叠的多边形、裁剪等,并将结果写入帧缓冲区。 帧缓冲区包含我们称之为一帧的 2D 图像,这就是最终显示在屏幕上的内容。 在现代硬件上,这项工作的大部分由 GPU 完成。在 **Playdate** 上,由于没有 GPU,CPU 必须转换多边形顶点、投影、裁剪、排序、着色/纹理化,并将生成的像素写入帧缓冲区。而且它需要足够快地完成这些操作,以每秒生成许多新帧。 这就是挑战所在。问题不仅仅是“它能绘制 3D 吗?”即使是 **ZX Spectrum**,只要给它足够的时间,也能绘制 3D。真正的问题是“它能足够快地进行实时游戏吗?”。 ZX Spectrum 绘制 3D 图像 ## BSP 我的渲染器加载 **Quake** 的 BSP 地图文件。我选择这个格式有两个主要原因。 首先,这意味着我不必从头编写关卡编辑器和地图编译器。我可以使用 **TrenchBroom** 进行关卡设计,使用 **ericw-tools** 来编译地图、可见性数据和光照。这为我节省了大量的时间。 其次,**Quake** 的 BSP 格式旨在预先编译所有内容,以加速在慢速机器上的渲染,这正是我需要的。 BSP 代表二进制空间分区(Binary Space Partitioning)。基本思想是用平面递归地分割空间。在 3D 中,每次分割将世界分为两个半空间。重复这个过程会生成一棵树。内部节点包含分割平面,叶子节点包含空间区域。在 **Quake** 风格的 BSP 中,这些叶子节点代表世界的凸块,每个叶子节点知道哪些面与之接触,以及从该叶子节点可能看到哪些其他叶子节点。 BSP(https://www.robotrenegade.com/articles/id-tech-3-optimization/vis.html) 地图编译器预先完成了大量昂贵的计算。它获取笔刷几何体,将其分割成 BSP 树,计算叶子节点之间的可见性,并将该可见性存储为 PVS(潜在可见集,Potentially Visible Set)。在运行时,渲染器找到包含摄像机的叶子节点,并使用其 PVS 在光栅化开始之前拒绝地图的大部分区域。这节省了大量时间。 PVS(https://www.robotrenegade.com/articles/id-tech-3-optimization/vis.html) 缺点是这个方法偏好静态世界。它还要求关卡在设计时考虑可见性。例如,连接两个大房间的直走廊可能会一次性暴露太多几何体,所以 L 形走廊通常更好,因为它阻挡了视线,并为 PVS 提供了更多移除不可见几何体的机会。 使用 BSP 使这个项目更加现实。重用工具为我节省了时间。检查现有工具并看看是否可以重用和调整它们以满足你的需求,而不是重新发明轮子,这总是一个好主意。使用成熟、经过实战考验的映射、编译、可见性和光照工具,让我可以把时间花在项目最重要的部分上。 我完全从头开始制作的是实际的 3D 软件渲染器。我不想仅仅移植 **Quake** 或使用别人的代码。我想自己构建它,这样我才能确保自己理解它,才能对其进行迭代,测试各种想法,并找到游戏的外观。 ## 用不用 z 缓冲 z 缓冲是一种深度缓冲。标准的深度缓冲存储当前绘制像素距离摄像机的距离。当渲染器想要绘制一个新像素时,它会将该像素的深度与 z 缓冲中已经存储的值进行比较。这就是渲染器防止远处物体被绘制到近处物体之上的方法。 深度缓冲(https://waspdev.com/articles/2025-05-09/the-power-of-z-buffer) 我的早期版本没有使用 z 缓冲,我尝试了经典的画家算法:从后向前绘制。 但经过测试,这种没有 z 缓冲的版本并没有给我带来有意义的性能提升,反而带来了更多的麻烦。 所以我添加了 z 缓冲。 我使用了 16 位倒数深度缓冲。它不直接存储线性距离,而是存储基于深度倒数的值。这在我更靠近摄像机的地方提供了更高的精度,因为那里小的深度错误更容易被注意到,而且它足够大,避免了我在使用较小的 8 位深度缓冲时看到的伪影。 z 缓冲也使动态物体和遮罩纹理更容易处理。门、电梯或可拾取物品可以使用与关卡几何体相同的深度测试,而镂空纹理,如水、玻璃、栏杆或精灵,可以简单地跳过不可见像素,同时正常绘制和深度测试可见像素。 水面动画 ## 透视校正纹理映射 我尝试了不同的方法,看看能达到什么效果。 最廉价的方法是仿射纹理映射。使用仿射映射,你直接在屏幕上插值纹理坐标。它很快,但只对平行于屏幕的多边形才是正确的,否则纹理开始扭曲。还记得 **PlayStation** 的纹理吗? PlayStation 纹理映射 在我的例子中,扭曲程度足够明显。通常防止这种情况的做法是细分几何体,但我决定不这样做,而是首先对透视校正映射进行基准测试。 我的渲染器使用仿射映射 我采用了经典的透视校正纹理映射。渲染器不会直接在多边形上插值纹理坐标(这会导致你在 PlayStation 示例中看到的扭曲),而是插值在投影后数学上保持正确的值。具体来说,它插值 1/z(深度倒数)、u/z 和 v/z,然后通过除法重建每个像素的实际纹理坐标。 这样可以得到准确的纹理而没有扭曲,但代价是:每个像素都需要一个除法(倒数)来重建坐标,这在 **Playdate** 的 CPU 上是昂贵的。所以我稍微作弊了一下:我只每隔几个像素计算一次完整的倒数,然后对中间的像素使用快速近似,从前一个值进行细化。视觉差异可以忽略不计,但性能收益是显著的,特别是考虑到纹理映射是渲染器中最昂贵的部分之一。 透视校正纹理映射 ## 在 1 位显示器上的光照 光照是渲染器中最奇怪的部分之一,因为 **Playdate** 屏幕无法显示颜色,甚至连灰度都没有。每个像素要么是黑色,要么是白色。 **Quake** BSP 文件包括预计算的光照图(lightmaps),而 **ericw-tools** 可以生成具有反弹光照和辐射度等功能的改进光照。我的渲染器读取这些数据,并将其转换为预计算的顶点光照信息,这样在运行时使用起来更便宜。 尽管如此,在 1 位显示器上你只能输出黑色或白色像素。那么如何在 1 位屏幕上显示亮度呢? 答案是抖动(dithering)。 抖动是一种通过排列黑白像素图案来模拟中间色调的方法。单个像素仍然只是黑色或白色,但一组像素根据这些像素的比例和排列看起来可以更暗或更亮。从远处看,或者在图像移动时,眼睛会将图案混合成感知到的色调。 抖动图案 渲染器使用一个 8x8 的 Bayer 矩阵。对于每个像素,渲染器将计算出的亮度与该像素在 Bayer 图案中位置的阈值进行比较。如果亮度高于阈值,该像素变为白色。如果低于阈值,该像素变为黑色。 在一个区域上,这创造了灰度的印象,尽管最终图像只使用黑色和白色。 难点在于抖动很容易变成噪声。一个完全纹理化、完全照明的场景可能在技术上令人印象深刻,但在 1 位显示器上视觉上难以辨认。这就是我的下一场战斗。 《毁灭战士》1 位噪声 ## 找到合适的外观 我的主要视觉参考之一是《奥伯拉丁的回归》(Return of the Obra Dinn)。我也喜欢《街头涂鸦》(Jet Set Radio)的外观。所以从一开始我就知道我想要强烈的轮廓线和简单的纹理。类似于 1 位卡通渲染的外观。 《奥伯拉丁的回归》 《街头涂鸦》 我仍然测试了默认方法:标准纹理,应用光照,然后进行 1 位抖动。 编辑器中的测试地图 渲染器噪声过多 正如我所怀疑的,这太嘈杂了。几何体和纹理难以辨认。经过更多迭代,它可能会得到改善,但这个测试向我证明,极简主义的卡通渲染风格更有意义。 然后我测试了使用简单纹理和强烈轮廓线的地图,即 1 位卡通渲染风格。 1 位卡通渲染风格 1 位卡通渲染风格 1 位卡通渲染风格 这立即提高了可读性。最重要的是,它给了游戏更强的标识性。我喜欢这种外观。我知道这就是方向。 纹理在地图编辑器中看起来有点傻,但在游戏中效果很好,这才是重要的。 编辑器中的傻傻纹理 ## 游戏中的傻傻纹理 ## 优化时间 我尝试了各种技巧,并对每次更改进行基准测试,试图找到最适合 **Playdate** 的方法。以下是我的发现: - 使用这些编译器标志显著提高了性能: - **-mcpu=cortex-m7 -mthumb**:告诉编译器为 Playdate 使用的 ARM Cortex-M7 CPU 生成并调整代码。 - **-mfpu=fpv5-sp-d16**:告诉编译器 Playdate 所拥有的确切 FPU(https://developer.arm.com/documentation/ddi0489/f/floating-point-unit/about-the-fpu),以生成适当的浮点指令。 - **-mfloat-abi=hard**:启用硬浮点 ABI,允许通过 FPU 寄存器传递浮点值,减少开销。 - **-Ofast**:启用非常激进的优化。在 Playdate 上,较小的代码有时比更激进优化的代码运行得更快,所以在选择之前,我针对 -O2、-O3 和 -Os 进行了基准测试。 - **-Wl,--gc-sections -flto -fdata-sections -ffunction-sections**:有助于剥离设备构建中的未使用代码,减小二进制文件大小,优化缓存使用。 - **-fno-unwind-tables**:通过移除游戏在运行时不需要的堆栈展开元数据来减小二进制文件大小。 - **-fno-asynchronous-unwind-tables**:从二进制文件中移除更多元数据,使设备构建更小。 - Playdate 的建议(https://sdk.play.date/3.0.6/Inside%20Playdate%20with%20C.html#_floating_point_math_operations): - **-Wdouble-promotion**:当 float 隐式提升为 double 时发出警告。我个人认为这总是有用的,但这里我们真的想避免意外使用 double。 - **-fsingle-precision-constant**:这使得任何浮点常量值默认为单精度。我总是使用 x = 0.1f 而不是 x = 0.1,但确保我们不要使用 double 仍然是个好主意。 - 使用 ARM Cortex-M7 汇编(https://developer.arm.com/documentation/dui0646/c/The-Cortex-M7-Instruction-Set/Floating-point-instructions)处理浮点指令

相似文章

像1993年那样制作图形

Hacker News Top

一位开发者详细介绍了如何构建《Catlantean 3D》——一款采用1993年时代图形技术(256色、320x240分辨率、手工制作资产、无人工智能)的第一人称射击游戏,计划在Steam上发布,重点讲解调色板渲染和资产创建。

这个周末你打算做什么?

Lobsters Hottest

一位开发者描述了将《完美黑暗64》关卡移植到 noclip.website 的过程,强调了读取 N64 显示列表和重新实现渲染引擎的挑战。

用500行纯C++实现软件渲染

Lobsters Hottest

本教程系列教你如何用500行C++从头编写一个软件渲染器,并解释现代3D图形API的内部工作原理。