在FPGA上重现Voodoo Graphics和90年代末期的游戏电脑
摘要
这篇文章详细介绍了zSST的创建,这是一个用于FPGA的3dfx Voodoo Graphics的SystemVerilog实现,使得配备Voodoo显卡的DOS电脑能够运行如《古墓丽影》等经典游戏。
暂无内容
查看缓存全文
缓存时间: 2026/09/16 02:58
# 在FPGA上重现Voodoo图形与1990年代末期游戏电脑
来源:https://nand2mario.github.io/posts/2026/zsst-voodoo/
过去一个月我一直在为z486_MiSTer (https://github.com/nand2mario/z486_MiSTer) 添加功能并提升性能,主要处理1990年代上半期的游戏。当我展望更早几年的技术变革时,另一个我期待探索的演进方向浮现出来:3D图形加速卡的诞生。
第一款给我留下深刻印象的是Voodoo显卡。那款游戏是*极品飞车II:特别版*。平滑的纹理、雾化效果以及整体的速度感,都让人感觉PC游戏进入了一个全新的世代。既然z486 CPU已经存在,我能否现在就在FPGA上重现这种体验呢?
这次探索的成果是zSST (https://github.com/nand2mario/zSST),一个用SystemVerilog实现的3dfx Voodoo Graphics(即SST-1)芯片。它与我设计的z486 CPU以及周边的PC硬件相结合,形成了z486 XL (https://github.com/nand2mario/z486_XL):一台运行在Xilinx KV260板卡可编程逻辑中的DOS PC,配备了Voodoo图形卡。现在《古墓丽影》可以使用其原始的3dfx渲染器运行了。
zSST实现了Voodoo的大多数核心功能:预处理三角形、纹理过滤与Mipmap、深度与Alpha测试、雾化、混合、抖动、帧缓冲访问以及缓冲区交换。它同时支持定点数和浮点数设置接口。目前硬件游戏测试仍集中在《古墓丽影》上;更广泛的兼容性以及后续Voodoo版本的支持留待日后完善。
CPU和渲染器在KV260 (https://www.amd.com/en/products/system-on-modules/kria/k26/kv260-vision-starter-kit.html) 上以100 MHz运行。该板卡拥有足够的逻辑资源、DSP单元、片上内存和DDR带宽来承载这个整合设计。DE10-Nano则没有空间容纳这个图形扩展。KV260使用板载DDR;无法额外添加SDRAM模块。
## 从编程模型入手
幸运的是,有大量资料可供参考。3dfx在NVIDIA于2000年12月收购其核心图形资产之前,于1999年发布了Glide源代码。留存下来的Glide源代码 (https://github.com/sezero/glide) 和SST-1规格书 (https://bitsavers.computerhistory.org/components/3dfx/Voodoo1_SST-1_Spec_r1.61_199912.pdf) 详细说明了软件如何准备三角形、配置像素流水线以及管理纹理和帧缓冲。
这份规格书是行为描述性的,而非电路图。它说明了软件写入寄存器时应该发生什么,但留下了许多实现选择。86Box (https://github.com/86Box/86Box) 为复杂的渲染行为提供了有用的参考。更早的MAME Voodoo工作 (https://aarongiles.com/programming/war-mame/) 是这段保存历史的另一部分。SpinalVoodoo (https://github.com/fayalalebrun/SpinalVoodoo) 则提供了特别有用的Glide跟踪和用于测试的参考截图。
## 从三角形到3D图像:每时钟一个像素
Voodoo Graphics将三角形转化为像素,将大部分3D变换工作留给主机CPU。它的命令接口出奇地紧凑:五个主要命令寄存器驱动着加速器。
寄存器 | 动作
-------|------
`triangleCMD` | 开始渲染一个已准备好的三角形。
`ftriangleCMD` | 通过浮点设置接口启动一个三角形。
`nopCMD` | 刷新流水线;可选择重置统计计数器。
`fastfillCMD` | 清除一个裁剪后的矩形区域,填充颜色和/或深度数据。
`swapbufferCMD` | 切换显示缓冲区,可以立即切换或与垂直回扫同步。
两种三角形命令都启动相同的渲染流水线。其他寄存器保存坐标、梯度(gradient)和渲染状态,而内存映射区域提供纹理上传和直接帧缓冲访问。主要的图元就是一个预处理好的三角形。
对于游戏开发者,Glide提供了更友好的接口:
```c
void grDrawTriangle(const GrVertex *a, const GrVertex *b, const GrVertex *c);
```
在此调用之前,主机会转换3D几何体、计算顶点光照、进行裁剪并投影到屏幕上。然后Glide准备屏幕空间三角形及其参数梯度(即在其表面进行插值计算时使用的增量),并写入三角形命令以启动渲染。与后来的GPU(如GeForce 256)不同,SST-1没有硬件变换与光照(T&L)引擎。
这仍然给加速器留下了大量工作。光栅化器找出哪些像素中心位于三角形内部,并插值计算它们的颜色、深度和纹理坐标。纹理单元获取并过滤纹素(texel);帧缓冲单元组合颜色、应用可见性测试和雾化、与现有图像混合,并写入结果。
一个三角形光栅化器向流水线供应像素,同时处理多个像素。一个像素需要多个阶段才能完成,而新像素可以持续进入。原始显卡将这项工作分配在两个ASIC之间:**FBI**(帧缓冲接口)和**TREX**(纹理映射单元,通常称为TMU)。在50 MHz图形时钟下,其标称峰值是每个时钟输出一个经过纹理映射、深度测试的像素:每秒5000万像素。
每时钟一个像素并不意味着一个像素在一个时钟内完成。它意味着不同的阶段可以同时处理不同的像素:当一个像素正在进行纹理映射时,前一个像素可以进行混合,而另一个像素可以被写入。一旦流水线填满,在理想情况下(假设内存速度足够),它可以每个时钟接受并完成一个像素。
这就是固定功能流水线的吸引力。软件渲染器为每个像素执行许多指令;专用硬件将这些工作重叠在稳定的像素流上。Voodoo让纹理丰富的3D游戏在流畅的30帧/秒甚至更高帧率下运行——这是它如此受欢迎的重要原因之一。
## 构建像素流水线
与x86 CPU相比,算术通路显得规整而愉悦。让我们跟随一个像素,从其插值参数经过纹理和颜色操作,直至帧缓冲,首先从数字的表示方式开始。
### 浮点接口背后的定点数
浮点运算在现代GPU编程中至关重要。SST-1处于一个有趣的过渡阶段:软件可以提交浮点值,但渲染引擎主要在定点数下操作——即带有隐含比例因子的整数。
设置值 | 定点寄存器格式
-------|------
屏幕X和Y | 12.4
红、绿、蓝、Alpha | 12.12
深度Z | 20.12
纹理S/W和T/W | 14.18
W的倒数 | 2.30
这里`12.4`表示小数点前12位(包括符号位),小数点后4位。因此屏幕坐标10.5存储为整数168:乘以16进行编码,除以16以恢复数值。这些小数位使得光栅化器能够处理像素中心之间的顶点。
`fvertex`、`fstart`和浮点梯度寄存器接受IEEE单精度值。SST-1将其转换为内部定点表示,zSST也遵循此约定。一旦三角形准备就绪,沿着扫描线推进主要意味着为每个插值参数加上一个预计算的增量。许多逐像素的工作变成了简单的整数加法。
### 一个像素需要四个纹素
为了进行透视校正纹理映射,TMU插值计算S/W、T/W和1/W,然后用前两个除以第三个以恢复纹理坐标。这使得地板或墙壁纹理在表面后退时能保持透视正确。TMU还会选择一个Mip级别 (https://en.wikipedia.org/wiki/Mipmap):即纹理的一个较小版本,用于覆盖其表面较大区域的像素。这减少了远处的锯齿和闪烁现象。
然后,双线性过滤会组合围绕采样位置的四个相邻纹素(纹理像素)。首先水平混合顶部一对,然后混合底部一对,最后在两者之间进行垂直混合。小数位置决定了权重,从而在纹素颜色之间产生平滑过渡,而不是从一个到下一个的突兀跳变。
四个相邻纹素中心,进行两次水平插值和一次垂直插值。四次纹理读取产生一个过滤后的采样值。在zSST中,一个四级前端流水线处理透视和细节级别计算。地址生成和缓存查找提供纹素,两个寄存器解码级将它们存储的格式转换为用于过滤和纹理组合的颜色。基于调色板和NCC编码的纹理需要不同的解码规则,但最终都馈入相同的像素流。
### 颜色、测试、雾化和混合
一旦纹理和帧缓冲数据就绪,zSST的FBI像素通路使用六个寄存器级:
阶段 | 主要工作
----|------
F0 | 选择来源、检查色度键、准备Z/W深度值。
F1 | 应用颜色和Alpha组合函数。
F2a | 测试Alpha/深度并查找雾化因子。
F2b | 应用雾化。
F3 | 重建目标颜色并执行Alpha混合。
F4 | 转换为帧缓冲精度、抖动并应用写掩码。
这些阶段边界的选择是为了满足FPGA的时钟目标。SST-1规格书描述了操作,但并未透露原始ASIC的确切流水线寄存器划分。例如,将雾化查找与雾化应用分开,可以将一个长算术路径移出单个时钟周期,同时保留每个时钟接受一个像素的能力。
结果被写入后缓冲区。然后执行一个与回扫同步的缓冲区交换,在扫描输出过程中不切换缓冲区,直接显示完成的图像。原始Voodoo只是一个3D附加卡,在非活动时传递普通VGA卡的输出。z486 XL在FPGA系统内部在PC的VGA输出和zSST的显示输出之间做出类似的选择。
## 难点:从内存提供数据
zSST像素流水线的实证明相对直接,至少与z486的CPU流水线相比。然而,保持其数据供给则要困难得多。一个双线性采样需要来自四个不同地址的纹素。一个经过深度测试、混合的像素还需要现有的深度和颜色,随后写入新的值。逐个执行这些访问会迅速破坏吞吐量。我最终在设计、调优和调试内存系统上花费的时间超过了算术流水线。
### 原始显卡如何提供像素
Diamond Monster 3D板卡照片,TMU及其纹理RAM位于FBI和帧缓冲RAM上方 (https://nand2mario.github.io/posts/2026/zsst-voodoo/diamond-monster3d-voodoo1.jpg)。匹配的示意图:TMU和FBI各自通过64位接口连接四颗EDO芯片;箭头显示通过RAMDAC的纹理处理、过滤后的颜色、PCI命令和显示输出 (https://nand2mario.github.io/posts/2026/zsst-voodoo/voodoo-board-schematic.svg)
板卡及其分工,两种视图中芯片位置对应。连接是示意性的;点击任一图像可放大。
照片:Konstantin Lanzet;裁剪:Pittigrilli,维基共享资源 (https://commons.wikimedia.org/wiki/File:KL_Diamond_Monster3D_Voodoo_1.jpg)。照片许可:GFDL 1.2 或更高版本 (https://nand2mario.github.io/posts/2026/zsst-voodoo/GFDL-1.2.txt)。示意图:nand2mario。
在这块Diamond Monster 3D上可以看到分工。上方的3dfx芯片是**TMU**,下方的是**FBI**,每个芯片右侧各有四颗EDO RAM芯片。上方一组存储纹理;下方一组存储颜色和深度/Alpha缓冲区。
FBI和TMU各自拥有专用的64位内存通路。在纹理端,四路交织允许存储体读取独立地址,并行提供双线性过滤所需的四个相邻纹素。规格书 (第13页) (https://bitsavers.computerhistory.org/components/3dfx/Voodoo1_SST-1_Spec_r1.61_199912.pdf#page=13) 保证了与点采样相同的吞吐量,无需存储重复的纹素。
但如果两个相邻纹素落在同一个芯片中呢?技巧是将纹素分布在一个重复的二维模式中,而不是将图像分割成四个大区域。为偶数或奇数列和行的每个组合分配一个存储体,原因就一目了然了:
一个交替的A/B/C/D存储体布局:一个对齐的2×2窗口和一个跨越边界的窗口都包含所有四个存储体,允许每个存储体独立读取一次 (https://nand2mario.github.io/posts/2026/zsst-voodoo/texture-bank-interleave.svg)。移动采样点会改变存储体的位置,而不是数量。坐标是(列, 行);存储体字母说明原理,并非物理SST-1芯片编号。每个2×2窗口都包含A、B、C和D——即使是跨越水平和垂直块边界的橙色窗口。连续两列具有相反的奇偶性,连续两行也是如此。所有四种组合恰好出现一次,因此每个存储体提供一个纹素,无冲突。
纹理边缘和小型Mip级别需要稍加注意。SST-1使用2的幂次纹理尺寸,因此对于尺寸为2或更大的情况,环绕(wrap)能保持交替模式。在夹取边缘,或在Mip级别宽度或高度仅为一个纹素时,某些采样会重用同一个纹素。核心见解不变:快速的双线性过滤取决于安排内存,使得算术单元能同时接收到所有输入。
FBI将类似思想应用于颜色和深度/Alpha内存。其交织通路支持峰值每个时钟一个渲染像素,或清除时每时钟两个像素。一起处理相邻像素将读写成本分散到一条扫描线上。Fabien Sanglard的双像素解释 (https://fabiensanglard.net/3dfx_sst1/) 对此行为提供了有用的重建,尽管确切的ASIC存储体调度在编程指南中没有详细说明。
在50 MHz下,每个64位通路理论带宽为400 MB/s:总计800 MB/s,但用于不同的任务。TMU不能借用空闲的FBI带宽,反之亦然。这些专用总线和精心安排的存储体让我想起了在SNESTang (https://nand2mario.github.io/posts/2024/snes_design_0.3/) 等项目中探索的NES和SNES时代的设计:从内存中获取最大性能意味着围绕每个值所需的确切时间和位置进行设计。
### FPGA SoC上的变化
Voodoo的内存布局解释了它如何保持流水线忙碌,但我无法简单地将该设计移植到KV260上。该板卡拥有大得多的内存带宽,但没有任何EDO内存直接连接到渲染单元。相反,FPGA通过Zynq处理系统的AXI端口访问共享DDR。Linux、FPGA PC和显示扫描输出都在竞争这块内存。目标相同——保持像素流水线供给——但实现方式必须改变。
SST-1上独立的FBI和TMU EDO通路与KV260上的共享DDR和三个AXI客户端的比较。原始SST-1拥有专用的纹理和帧缓冲总线。zSST共享其渲染器端口,并使用缓冲来容忍DDR延迟。
我们对KV260的测量表明,为何仅有带宽是不够的。一个128位、100 MHz的端口理论带宽为1.6 GB/s。在单次请求未完成的情况下,一次4 KiB读取可达1,370 MiB/s,但一次64字节读取仅能达到189 MiB/s。首批数据通常需要约280纳秒才能到达——在100 MHz下约为28个时钟周期——偶尔等待时间更长。
测量的DDR带宽随突发长度增加而上升,而首个数据到达时间始终在280纳秒左右。单次未完成板卡测量。长突发可摊还延迟;小请求需要并发性。一个在每次小读取完成前不发出下一次请求的渲染器,大部分时间将处于空闲状态。zSST需要足够多的独立工作在飞行中,以覆盖这些等待。
### 缓存、重放缓冲与重排序缓冲区
保持流水线供给既需要更少的DDR访问,也需要减少等待它们的时间。第一步是**缓存**。靠近的屏幕像素通常具有相似的纹理坐标和颜色值。一个有效的缓存可以在本地内存块中存储最近访问的数据。当像素请求击中缓存时,数据可以立即返回,无需访问缓慢的DDR。对于常见的纹理访问模式(如重复纹理或局部空间相关性),缓存命中率可以很高。zSST为帧缓冲数据(包括颜色、深度和模板)以及纹理数据实现了缓存。
然而,仅仅缓存可能还不够。渲染器可能遇到需要多个内存访问的序列,而这些访问可能无法在缓存中找到。为了进一步隐藏内存延迟,zSST采用了一种**重排序缓冲区**。这种机制允许渲染器在遇到可能的缓存未命中或高延迟访问时,不立即停顿,而是继续处理后续的像素。通过将这些像素的内存请求放入重排序缓冲区,渲染器可以稍后(当数据到达时)重新排序并执行这些操作,从而最大限度地提高流水线的利用率。这种技术在现代CPU和GPU中很常见,但在资源受限的FPGA设计中,其规模需要仔细权衡。
最终,zSST通过结合缓存、精心设计的内存访问模式以及适度的重排序能力,在有限的资源下实现了足够高的有效带宽,从而保持了像素流水线的持续工作。这确保了即使在共享DDR环境和相对较高的延迟下,渲染器也能达到接近其理论峰值的吞吐量,最终实现了流畅的Voodoo图形渲染。
相似文章
在我们的自定义CPU上运行Doom并走红
作者描述了在逻辑门级别设计自定义CPU、集成带有缓存的DDR3内存,并成功在FPGA上运行Doom的经历,该经历随后走红网络。
z386:基于原始微码构建的开源80386
本文详述了z386,一款基于原始Intel微码构建的开源FPGA 80386 CPU。它能引导DOS 6/7、运行保护模式程序,并玩经典游戏如Doom,既是一种教育性重构,也是一个可用的FPGA CPU。
打造一台1997年的Quake PC:对GLquake进行基准测试
一位开发者讲述了他组装一台配备3dfx Voodoo显卡的复古PC,并对GLQuake进行基准测试的过程,同时讨论了硬件的小问题和性能印象。
将我的3D点云渲染器移植到ZX Spectrum 48K上
一位开发者将3D点云渲染器移植到ZX Spectrum 48K上,通过Z80汇编优化达到每秒14帧,并创建了一个预计算版本,运行速度为每秒40帧。
VGA 内存访问复杂
本文探讨了 VGA 内存访问的复杂性,以及由于文档不完善而在模拟旧硬件方面所面临的挑战。