将我的3D点云渲染器移植到ZX Spectrum 48K上
摘要
一位开发者将3D点云渲染器移植到ZX Spectrum 48K上,通过Z80汇编优化达到每秒14帧,并创建了一个预计算版本,运行速度为每秒40帧。
查看缓存全文
缓存时间: 2026/05/18 15:56
ttsiodras/3D-on-a-ZX-Spectrum-48K
来源:https://github.com/ttsiodras/3D-on-a-ZX-Spectrum-48K
背景故事
一切都始于那把音箱珍宝——确切地说,是 ZX Spectrum 48K+。
13岁那年我得到了它——这是有史以来最好的礼物:
Speccy
3D 折腾
自那以后,整个职业生涯都过去了。
一路走来,我的一个习惯就是在空闲时间瞎捣鼓纯软件 3D 图形(https://www.thanassis.space/renderer.html)。
事实上,几年前,我将其主要逻辑(https://github.com/ttsiodras/3D-on-an-ATmega328p/)移植到了 ATmega328P 微控制器上,实现了“纯点”3D 渲染,并通过 SPI 接口驱动 OLED 显示屏……分辨率高达 128x64 :-)
在 ATmega328P 上实时 3D(https://youtu.be/nsqmnkfZtSw)
挑战——在 Speccy 上运行
于是,通往更无用折腾的道路清晰了:
我就是非得让它在 Speccy 上也跑起来!:-)
正如你在本仓库中所见……我做到了:
在 ZX Spectrum 48K 上实时 3D(https://youtu.be/IJQAdUcj330)
生成的 statue.tap 文件也已提交到仓库中,如果你只想在 FUSE 模拟器中快速运行的话。我还添加了第二个球体 3D 模型——在 FUSE 中运行 sphere.tap 即可看到结果:
$ fuse -g tv3x tap/statue.tap ... $ fuse -g tv3x tap/sphere.tap
编译
有一个简单的 Makefile 驱动构建过程——所以一旦你安装了 z88dk,只需输入:
make clean all run
编译所用的交叉编译器是 z88dk(https://www.z88dk.org/)。如果你的发行版中没有打包,可以轻松从源码构建:
mkdir -p ~/Github/
cd ~/Github/
git clone https://github.com/z88dk/z88dk/
cd z88dk
git submodule init
git submodule update
./build.sh -p zx
现在你可以使用交叉编译器了——只需配置好环境(例如,在 .profile 中):
export PATH=$HOME/Github/z88dk/bin:$PATH
export ZCCCFG=$HOME/Github/z88dk/lib/config
关于 3D 投影和 Z80 汇编
由于 Speccy 的大脑比 ATmega328P 还要小,我不得不采取更多自由发挥:我将计算循环改为轨道环绕视点(而不是旋转雕像),从而得到最简单的方程:
int wxnew = points[i][0]-mcos;
int x = 128 + ((points[i][1]+msin)/wxnew);
int y = 96 - (points[i][2]/wxnew);
没有乘法,没有移位;只有两次除法,以及几次加减法。
如果你想知道这怎么可能是一个有效的 3D 投影,你可以阅读下面“给极客的数学”部分,里面全是数学推导 :-)
但这还没有结束——如果要怀旧,就得彻底做!
所以过了将近四十年,我重写了 Z80 汇编——并比任何 C 编译器都更好地利用了 Z80 寄存器(https://github.com/ttsiodras/3D-on-a-ZX-Spectrum-48K/blob/master/src/statue.c#L88)(https://retrocomputing.stackexchange.com/questions/6095/)。
我还用倒数查找表替换了两次昂贵的除法操作为两次乘法。事实上,我大量使用了“页式”查找(mov H, hi-byte-of-table-offset;将索引加载到 L 中——从 (HL) 读取)来实现最终惊人的加速:
- 从 6.2 帧/秒(C 语言)
- ……提升到 14.0 帧/秒(优化的汇编)
开心 :-)
预计算以达到最高速度
我也有点好奇预计算整个路径和屏幕内存写入——你可以在 precompute(https://github.com/ttsiodras/3D-on-a-ZX-Spectrum-48K/tree/precompute)分支中看到那段代码。
在 ZX Spectrum 48K 上预计算的 3D 动画(https://youtu.be/_-eZoSKz0HM)
如上视频所示,这个版本运行速度提升了 4 倍,达到 40 帧/秒。不过预计算所有内容需要花几分钟时间。既然我有足够的时间来预计算,我在 8.8 定点算术中使用了完整的方程(用于旋转雕像和 3D 投影(https://github.com/ttsiodras/3D-on-a-ZX-Spectrum-48K/blob/precompute/statue.c#L42)):
3D 代数
速度如此疯狂的原因在于,我预计算了目标像素的视频 RAM 位置和像素偏移,最终内循环(https://github.com/ttsiodras/3D-on-a-ZX-Spectrum-48K/blob/precompute/statue.c#L179)几乎什么都不用做,只需从 16 位/像素中提取内存访问坐标:
- 视频 RAM 6K 内的偏移量,在高 13 位中
- 该字节内的像素(0-7),在低 3 位中
同样值得注意的是,内联汇编版本的“blitter”(https://github.com/ttsiodras/3D-on-a-ZX-Spectrum-48K/blob/precompute/statue.c#L91)比 C 版本(https://github.com/ttsiodras/3D-on-a-ZX-Spectrum-48K/blob/precompute/statue.c#L175)快 3.5 倍。我还可以进一步优化……但没这个必要了 :-)
由于这些只是读取、移位和写入,我承认我没想到会有这么大的差异……但很明显,Z80 的 C 编译器需要所有能得到的帮助(https://retrocomputing.stackexchange.com/questions/6095/):-)
附录:给数学极客——投影如何工作
开始:从原始浮点数据到 ZX Spectrum 屏幕像素,每一步都有解释。
1. 源数据
雕像模型由 153 个 3D 点组成,以浮点数值存储在 statue_data.py 中。这些点由 points_gen.py 处理成一个二进制块(points.bin),嵌入到 Speccy 的内存中——即“烧录”在 .tap 文件内,运行时直接使用。存在一些预缩放,将它们转换为整数值(预缩放因子 S = 8960);简单来说,运行时使用整数是为了避免 Z80 不支持浮点运算!
1.1. 浮点到整数转换
缩放因子 S = 8960 在三个轴上一致——例如……
{ 0.131, 0.116, -0.501 } x 8960 -> { 1174, 1039, -4488 }
注意,每个轴的实际范围是不对称的:模型并不居中。
2. 预处理(构建流水线中)
为了最大化运行时性能,坐标不仅被缩放,还在嵌入二进制之前由 points_gen.py 进行了预变换。
具体如下。
2.1. 轴交换
首先进行交换:
tmp = Y; Y = Z; Z = tmp
存储顺序变为 [X, Z, Y] 而不是 [X, Y, Z]。然后,逐点循环从 (HL) 读取时,可以先计算深度和屏幕 Y;对于垂直越界的点,可以跳过计算屏幕 X。
这是一个简单的优化,当我们放大到雕像超出边界时,能显著提升性能。
2.2. 点坐标
坐标还被转换为“屏幕就绪”的定点空间:
分量 | 原始公式 | 轴交换后
--------------+----------------------------------+--------------------
X' | SE - X_raw / 14 | 深度轴
Y' | Y_raw / 9 * 64 | 屏幕 X 轴 [2]
Z' | Z_raw / 9 * 64 | 屏幕 Y 轴 [1]
我知道,这看起来很神秘——请耐心继续阅读 :-)
令 SE = 415(256 + MAXX/16),并且由于我们按 S = 8960 缩放:
X' = 415 - X_float * 640
Y' = Y_float * 63795.6
Z' = Z_float * 63795.6
这就是我们变换后的最终整数数据的样子。
2.3. 正弦/余弦(相机轨道)
sin/cos 表在 tables_gen.py 中预计算。为了匹配轨道半径并在不进行运行时缩放的情况下保持 16 位精度,我们额外做了以下操作:
sin_val = (sin_raw / 3) * 64
cos_val = cos_raw / 3
……其中原始值按 T = 256 缩放:
msin = sin(theta) * T * 64 / 3 = sin(theta) * 5461.3
mcos = cos(theta) * T / 3 = cos(theta) * 85.3
这些不同的缩放因子设定了相机轨道半径;经过微调以完美匹配模型大小。
但等等——为什么 sin 缩放与 cos 不同?
嗯,在最终方程(接下来)中,mcos 只需要将相机深度偏移轨道半径——这是一个适中的偏移。T/3 = 85.3 正好合适。
相比之下,msin 随后要被 wxnew 除并加到水平屏幕位置上。为了产生有意义的像素偏移,它需要大得多。乘以 64 后,T/3 x 64 = 5461.3——经过除法后产生合理的水平摆动。
简单来说:这种不对称是有意为之!两者都代表角度,但一个控制相机多远(mcos -> 分母),另一个控制点在屏幕上摆动多远(msin -> 分子/分母 -> 屏幕像素)。
3. drawPoints 内部的投影方程
因此最终,我们的方程执行了最简单可能的投影;运行时没有乘法,只有两次除法和几次加法。
wxnew = X' - mcos
y = 96 - Z' / wxnew
x = 128 + (Y' + msin) / wxnew
这如何工作?让我们看看……
3.1. 以世界单位完整推导
96 和 128 是 Speccy 屏幕的中点(256x192)。
如果我们展开方程并从深度项中提取因子 640,得到:
wxnew = X' - mcos
wxnew = (415 - X_float * 640) - (cos(theta) * 5461.3)
wxnew = 640 * (415/640 - X_float - cos(theta) * 5461.3/640)
= 640 * (0.6484 - X_float - cos(theta) * 8.533)
现在,如果我们将投影除法中的分子和分母都除以 640……
y = 96 - Z' / wxnew
x = 128 + (Y' + msin) / wxnew
……方程变为:
Z_float * 99.68
y_screen = 96 - -------------------------------------------
0.6484 - X_float - cos(theta) * 8.533
Y_float * 99.68 + sin(theta) * 8.533
x_screen = 128 + ---------------------------------------------
0.6484 - X_float - cos(theta) * 8.533
如果我们将深度定义为正距离:
depth = X_float + d * cos(theta) - d0
……并且……
f ~ 99.68 px 焦距 = S / 90
d ~ 8.53 单位 轨道半径 = T * 64 / (3 * 640)
d0 ~ 0.65 单位 SE 偏移 = 415 / 640
……那么完整的投影方程变为:
f * Z
y_screen = 96 - ---------
depth
f * Y + d * sin(theta)
x_screen = 128 + ------------------------
depth
现在很清楚,这些是标准的 3D 投影方程;
(见下图)——其中 d*sin(theta) 通过我们应用的旋转偏移了相机的视点。
3D 代数,为方便起见重复
3.2. 每个参数的含义
参数 | 值 | 推导 | 作用
-------------+-------------+-------------------------------+----------------------------
f | 99.68 px | S * 64/9 / 640 = S / 90 | 焦距,控制投影大小
d | 8.53 单位 | T * 64/3 / 640 = 256/30 | 相机轨道半径
d0 | 0.65 单位 | SE / 640 = 415 / 640 | 保持模型在相机前方
屏幕中心 | (128, 96) | - | 256 x 192 的一半
视角方向 | +X | - | 相机沿 +X 轴看
4. 相机模型
相机绕着一个圆运动——围绕距离模型特定距离的一点。
camera_X(theta) = d * cos(theta) - d0 = 8.53 * cos(theta) - 0.65
4.1. 为什么 SE = 415?
SE = 256 + MAXX / 16 = 256 + 2551 / 16 = 256 + 159 = 415
256 屏幕宽度,水平居中模型
MAXX/16 考虑预处理后的 X 范围
没有 SE 的话,X’ = -X_float * 640 可能会在 X 为正时变成负数,导致除法符号翻转,将模型放在相机后面。SE 添加了一个常数偏移,使得 X’ 保持正数,并且对于所有模型点而言 depth > 0。
5. 总结:完整流水线
注意:下面的方程以 C 形式展示。汇编版本使用相同逻辑,通过倒数查找表将两次除法替换为乘法。
`statue_data.py` 中的浮点数据 {X, Y, Z}
|
| 构建流水线(`points_gen.py` 和 `tables_gen.py`)
v
`points.bin` 中的预变换坐标
(应用了 S=8960 缩放、轴交换和屏幕空间变换)
|
| 运行时循环
v
X' = 预计算的深度轴
Y' = 预计算的屏幕 X 轴(存储在 [2])
Z' = 预计算的屏幕 Y 轴(存储在 [1])
msin = 预计算的 sin(theta) 变换
mcos = 预计算的 cos(theta) 变换
|
| 每帧
v
wxnew = X' - mcos (来自相机的深度)
y = 96 - Z' / wxnew (透视 -> 垂直)
|
| 如果 0 <= y < 192:
v
x = 128 + (Y' + msin) / wxnew (透视 -> 水平)
byte_addr = ofs[y] + (x >> 3) (通过 scr_ofs 查找表完成)
bit_mask = 128 >> (x & 7) (通过 mask 查找表完成)
在字节中设置位
下一步——真机运行
现在我需要做的就是等待退休……这样我就能运用我的电子知识复活我的 Speccy,并在真机上测试这段代码,而不仅仅是在 Free Unix Spectrum Emulator 上测试 :-)
话说回来,也许好心的读者你,可以在你的 Speccy 上试试——然后告诉我它能否运行?
干杯! Thanassis。
相似文章
像1993年那样制作图形
一位开发者详细介绍了如何构建《Catlantean 3D》——一款采用1993年时代图形技术(256色、320x240分辨率、手工制作资产、无人工智能)的第一人称射击游戏,计划在Steam上发布,重点讲解调色板渲染和资产创建。
CGA上的60fps视频?– GlyphBlaster
GlyphBlaster是一款基于Raspberry Pi Pico的设备,它替换了CGA显卡上的字模ROM,通过将字模ROM读取作为像素可寻址帧缓冲区,在文本模式下实现了60fps的视频播放。
Boriel BASIC
Boriel BASIC 是一款现代开源的 BASIC 编译器 SDK,主要为 ZX Spectrum 设计,提供增强功能、整数类型以及内联汇编支持,适用于复古游戏开发。
给Fable一个提示:“构建一个.kkrieger的Linux致敬版本。”它在一个C文件中交付了一个51KB的程序化FPS——然后通过截取其无头渲染的截图并实际查看来进行调试
一位开发者用单个C文件创建了一个完全程序化生成的致敬.kkrieger的第一人称射击游戏,生成了一个51KB的二进制文件,在运行时合成所有资产,并通过无头截图进行了验证。
Soul Player C64 – 在 1 MHz Commodore 64 上运行的真正 Transformer
# gizmo64k/soulplayer-c64 来源:[https://github.com/gizmo64k/soulplayer-c64](https://github.com/gizmo64k/soulplayer-c64) # Soul Player C64 **一款在 1 MHz Commodore 64 上运行的真实 Transformer。** ``` .-------. | O O | | V | |..|---|..| # SOUL PLAYER C64 2.5万个参数。 2 层网络。 真实的 Transformer。 从软盘加载运行。 你> 嗨 C64> 你好!这声音不错。真神奇! ``` 一个 2 层仅解码器(Decoder-Only)Transformer —— 与 ChatGPT、Claude 和 Gemini 背后的架构相同 —— 采用手写 6502/