将我的3D点云渲染器移植到ZX Spectrum 48K上

Hacker News Top 新闻

摘要

一位开发者将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年那样制作图形

Hacker News Top

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

CGA上的60fps视频?– GlyphBlaster

Hacker News Top

GlyphBlaster是一款基于Raspberry Pi Pico的设备,它替换了CGA显卡上的字模ROM,通过将字模ROM读取作为像素可寻址帧缓冲区,在文本模式下实现了60fps的视频播放。

Boriel BASIC

Hacker News Top

Boriel BASIC 是一款现代开源的 BASIC 编译器 SDK,主要为 ZX Spectrum 设计,提供增强功能、整数类型以及内联汇编支持,适用于复古游戏开发。

Soul Player C64 – 在 1 MHz Commodore 64 上运行的真正 Transformer

Hacker News Top

# 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/