Nortis(以前称为 Notris)是一款自制 PSX 游戏,使用现代工具用 C 语言编写。它完全可以在原始硬件上播放,并由 PSNoobSDK 提供支持。
在此处查看 PSX 代码库。
去年,我得到了一台罕见的黑色 PlayStation 1。它被称为 Net Yaroze,是一款特殊的游戏机,可以玩自制游戏以及普通 PSX 游戏。这是索尼一个特殊项目的一部分,旨在让爱好者和学生进入游戏行业。
Yaroze 游戏非常有限,因为索尼不希望卧室程序员与商业开发商竞争。它们只能在其他 Yarozes 或特殊演示光盘上播放。它们必须完全适合系统 RAM,而不能访问 CD-ROM。尽管存在这些限制,Yaroze 仍然培育了一个充满热情的独立开发者社区。
现在我有了自己的了。这让我开始思考:编写 PlayStation 游戏到底是什么样的?
这是关于我如何自己编写一个简单的自制 PSX 游戏,使用库的开源版本,但仍然在原始硬件上运行并使用经典 C 编写。
跳过本节
PSX 游戏通常是在 Windows 9X 工作站上用 C 语言编写的。官方开发套件是一对 ISA 扩展卡,可插入常见的 IBM PC 主板,包含整个 PSX 系统芯片组、视频输出和额外 RAM(8mb 而不是 2mb)。这向主机提供了 TTY 和调试器输出。
您可能听说过蓝色 PlayStation。这些用于质量检查而不是开发,与零售设备相同,只是它们可以播放刻录的 CD-ROM。然而,至少有一家公司出售了一个特殊的插件来将它们转换成开发套件:
该设计对开发人员非常友好。您可以使用普通控制器在 CRT 上玩游戏,同时在 Windows 95 PC 上单步执行 GDB 断点,翻阅一本厚厚的 C SDK 函数教科书。
原则上,PSX 开发人员可以完全使用 C 语言工作。该 SDK 包含一组称为 PSY-Q 的 C 库,并包含一个编译器程序ccpsx
,它实际上只是 GCC 的前端。这支持一系列优化,例如代码内联和循环展开,尽管性能关键部分仍然需要手动优化组装。
(您可以在这些 SCEE 会议幻灯片中了解这些优化)。
C++ 受到ccpsx
支持,但因生成“臃肿”代码以及编译时间较慢而闻名。事实上,C 是 PSX 开发的通用语言,但有些项目在基本引擎之上使用了动态脚本语言。例如, 《合金装备》使用 TCL 进行关卡脚本编写;最终幻想游戏更进一步,为战斗、野外和迷你游戏系统实现了自己的字节码语言。 (您可以在这里了解更多相关信息)。
(如需进一步阅读,请访问 https://www.retroreversing.com/official-playStation-devkit )
跳过本节
但我是从一个非常不同的角度来看待这个问题的:2024 年,我是一名软件工程师,主要从事 Web 应用程序的工作。我的专业经验几乎都是高级语言,如 JavaScript 和 Haskell;我做了一些 OpenGL 工作和 C++,但现代 C++ 几乎是一种与 C 完全不同的语言。
我知道像 Rust 这样的语言存在 PSX SDK,但我想体验“真正的”PSX 编程的味道,就像 90 年代那样。因此,它将是现代工具链和开源库,但始终是 C。
该游戏需要是 2D 的,并且可以在几天内制作出原型。我选择了俄罗斯方块克隆版——我认为它足够复杂,足以体验我想要的东西。
第一步是用熟悉的技术构建原型。这将使我能够确定基本设计,然后可以将逻辑零碎地翻译成 C 语言。
作为 Web 开发人员,最明显的原型技术是 JavaScript:它简单、简洁、易于调试,并且支持 HTML5 <canvas>
图形 API。事情很快就解决了
同时,我担心更多高级 JavaScript 功能将难以移植。任何使用类或闭包的东西都需要完全重写,所以我小心地将自己限制在该语言的一个简单的、过程性的子集上。
现在,我做这个项目实际上是别有用心的:这是一个最终学习 C 语言的借口。这门语言在我的脑海中赫然浮现,我开始对不了解它产生一种自卑感。
C 有着令人生畏的声誉,我担心悬空指针、未对齐的读取和可怕的segmentation fault
等恐怖故事。更准确地说:我担心如果我尝试学习 C 但失败了,我会发现我实际上并不是一个很好的程序员。
为了让事情变得简单,我想我可以使用 SDL2 来处理输入和图形,并针对我的桌面环境 (MacOS) 进行编译。这将为我提供快速的构建/调试周期,并使学习曲线尽可能平缓。
尽管我很害怕,但我发现 C 非常有趣。很快我就“点击”了。您从非常简单的原语(结构、字符、函数)开始,并将它们构建成抽象层,最终发现自己位于整个工作系统之上。
游戏只花了几天时间就移植了,我对我的第一个真正的 C 项目非常满意。而且我没有出现任何段错误!
使用 SDL 很愉快,但有一些方面需要我动态分配内存。这在 PlayStation 上是禁忌,因为 PSX 内核提供的malloc
无法正常工作。图形管道将是一个更大的飞跃......
当谈到 PlayStation 自制软件时,您的 SDK 有两个主要选择。任何一个:
还有一些其他选项,例如 C++ Psy-Qo ,您甚至可以放弃任何 SDK 来自己进行内存映射 I/O - 但我没有足够的勇气这样做。
Psy-Q 的最大问题是,即使 30 年后,它仍然是索尼专有代码。从法律上讲,任何用它构建的自制软件都面临风险。这就是 Portal64 demake 失败的原因:它静态链接了libultra
,这是任天堂专有的 N64 SDK。
但说实话,我选择 PSNoobSDK 的主要原因是它的文档非常齐全且设置简单。该 API 与 Psy-Q非常相似:事实上,对于许多功能,我只需查阅 Yaroze 附带的打印参考资料即可。
如果我使用非正宗的 SDK 冒犯了您内心的 PSX 纯粹主义者,请立即厌恶地停止阅读。
我的第一个任务是一种“你好世界”:彩色背景上的两个正方形。听起来很简单,对吧?
跳过本节
(*其中一些已简化。有关更权威的指南,请阅读 PSNoobSDK 教程)
首先,将 PSX VRAM 视为一个 1024 x 512 16 位像素的大画布。总共使帧缓冲区和纹理共享 1 MB 内存。我们可以选择输出帧缓冲区的分辨率 - 如果我们贪心的话,甚至可以选择高达 640x480 像素 - 但更高的分辨率 = 更少的纹理。
大多数 PSOne 游戏(以及一般游戏)都有双缓冲渲染的概念:在准备一帧的同时,将另一帧发送到屏幕。所以我们需要分配两个帧缓冲区:
(现在您可以明白为什么 640x480 不实用了 - 没有足够的空间容纳两个 480p 缓冲区。但是这种模式可以用于 PSX 启动徽标等不需要太多动画的东西)
缓冲区(交替称为显示和绘制环境)每帧都会交换。大多数 PSX 游戏的目标是 30fps(在北美),但实际的 VSync 中断频率为 60hz。有些游戏能够以 60 fps 的速度运行 - 比如《铁拳 3》和 Kula World(Roll Away) - 但显然你需要用一半的时间进行渲染。请记住,我们只有 33 Mhz 的处理能力。
但是 - 绘图过程是如何进行的?这是由 GPU 完成的,但 PSX GPU 的工作方式与现代显卡非常不同。本质上,GPU 的每一帧都会收到一个图形“数据包”或命令的有序列表。 “在这里画一个三角形”,“加载这个纹理来为下一个四边形蒙皮”,等等。
GPU 不进行 3D 变换;这是 GTE(几何变换引擎)协处理器的工作。 GPU 命令代表纯 2D 图形,已由 3D 硬件操作。
这意味着 PSX 像素的路径如下:
所以在伪代码中 PSX 帧循环(基本上)是这样的
FrameBuffer [0, 1]
OrderingTable [0, 1]
id = 1 // flips every frame
loop {
// Game logic
// Construct the next screen by populating the current ordering table
MakeGraphics(OrderingTable[id])
// Wait for last draw to finish; wait for vertical blank
DrawSync()
VSync()
// The other frame has finished drawing in background, so display it
SetDisplay(Framebuffer[!id])
// Start drawing current frame
SetDrawing(Framebuffer[id])
// Send ordering table contents to GPU via DMA
Transfer(OrderingTable[id])
// Flip
id = !id
}
您可以从中看到,虽然第 1 帧在屏幕上,但第 2 帧仍在绘制,而第 3 帧可能仍由程序本身“构建”。然后在DrawSync / VSync之后我们将帧2发送到电视,并得到GPU绘制帧3。
如前所述,GPU 是一个完全 2D 硬件,它不知道 3D 空间中的 z 坐标。没有“z 缓冲区”来描述遮挡 - 即哪些对象位于其他对象的前面。那么物品在其他物品面前是如何排序的呢?
它的工作方式是排序表包含反向链接的图形命令链。这些从后到前遍历以实现画家算法。
准确地说,排序表是一个反向链表。每个项目都有一个指向列表中前一个项目的指针,我们通过将它们插入到链中来添加原语。通常,OT 被初始化为固定数组,数组中的每个元素代表显示中的一个“级别”或层。 OT 可以嵌套以实现复杂的场景。
下图有助于解释(来源)
这种方法并不完美,有时 PSX 几何体会显示奇怪的剪切,因为每个多边形只能位于屏幕空间中的单个“z 索引”,但它对于大多数游戏来说已经足够好了。如今,此类限制被认为是 PSX 独特魅力的一部分。
跳过本节
我们已经讨论了很多理论——这在实践中是什么样的?
本节不会逐行浏览所有代码,但应该让您初步了解 PSX 图形概念。如果您想查看完整代码,请转到hello-psx/main.c
。
或者,如果您不是编码员,请随意跳过。这仅适用于好奇的技术人员。
我们需要的第一件事是一些包含缓冲区的结构。我们将有一个包含两个RenderBuffers
的RenderContext
,每个RenderBuffer
将包含:
displayEnv
(指定当前显示缓冲区的VRAM区域)drawEnv
(指定当前绘制缓冲区的 VRAM 区域)orderingTable
(包含指向图形数据包的指针的反向链接列表)primitivesBuffer
(图形数据包/命令的结构 - 包括所有多边形) #define OT_SIZE 16
#define PACKETS_SIZE 20480
typedef struct {
DISPENV displayEnv ;
DRAWENV drawEnv ;
uint32_t orderingTable [ OT_SIZE ];
uint8_t primitivesBuffer [ PACKETS_SIZE ];
} RenderBuffer ;
typedef struct {
int bufferID ;
uint8_t * p_primitive ; // next primitive
RenderBuffer buffers [ 2 ];
} RenderContext ;
static RenderContext ctx = { 0 };
每一帧我们都会反转bufferID
这意味着我们可以在显示另一帧的同时无缝地处理一帧。一个关键细节是p_primitive
始终指向当前primitivesBuffer
中的下一个字节。每次分配原语并在每帧结束时重置时,该值都必须递增。
几乎在任何事情之前,我们需要以相反的配置设置显示和绘制环境,以便DISP_ENV_1
使用与DRAW_ENV_0
相同的 VRAM,反之亦然
// x y width height
SetDefDispEnv ( DISP_ENV_0 , 0 , 0 , 320 , 240 );
SetDefDispEnv ( DISP_ENV_1 , 0 , 240 , 320 , 240 );
SetDefDrawEnv ( DRAW_ENV_0 , 0 , 240 , 320 , 240 );
SetDefDrawEnv ( DRAW_ENV_1 , 0 , 0 , 320 , 240 );
我在这里非常浓缩 - 但从这里开始每一帧基本上都是这样
while ( 1 ) {
// do game stuff... create graphics for next frame...
// at the end of loop body
// wait for drawing to finish, wait for next vblank interval
DrawSync ( 0 );
VSync ( 0 );
DISPENV * p_dispenv = & ( ctx . buffers [ ctx . bufferID ]. displayEnv );
DRAWENV * p_drawenv = & ( ctx . buffers [ ctx . bufferID ]. drawEnv );
uint32_t * p_ordertable = ctx . buffers [ ctx . bufferID ]. orderingTable ;
// Set display and draw environments
PutDispEnv ( p_dispenv );
PutDrawEnv ( p_drawenv );
// Send ordering table commands to GPU via DMA, starting from the end of the table
DrawOTagEnv ( p_ordertable + OT_SIZE - 1 , p_drawEnv );
// Swap buffers and clear state for next frame
ctx . bufferID ^= 1 ;
ctx . p_primitive = ctx . buffers [ ctx . bufferID ]. primitivesBuffer ;
ClearOTagR ( ctx . buffers [ 0 ]. orderingTable , OT_SIZE );
}
这可能需要考虑很多。别担心。
如果你真的想理解这一点,最好的办法就是看看hello-psx/main.c
。所有内容都经过相当详细的评论。或者,阅读 PSNoobSDK 教程...它非常简洁且写得非常清楚。
现在...我们如何绘制东西?我们将结构写入基元缓冲区。该缓冲区的类型只是一个大的 ole chars
列表,因此我们将其转换为形状/命令结构,然后使用sizeof
推进基元缓冲区指针:
// Create a tile primitive in the primitive buffer
// We cast p_primitive as a TILE*, so that its char used as the head of the TILE struct
TILE * p_tile = ( TILE * ) p_primitive ;
setTile ( p_tile ); // very very important to call this macro
setXY0 ( p_tile , x , y );
setWH ( p_tile , width , width );
setRGB0 ( p_tile , 252 , 32 , 3 );
// Link into ordering table (z level 2)
int z = 2 ;
addPrim ( ordering_table [ buffer_id ] + z , p_primitive );
// Then advance buffer
ctx . p_primitive += sizeof ( TILE );
我们刚刚插入了一个黄色方块! ?尽量克制自己的兴奋。
跳过本节
在我的旅程中的这一点上,我真正拥有的只是一个“hello world”演示程序,具有基本的图形和控制器输入。您可以从hello-psx
中的代码中看到,我正在尽可能多地记录,实际上是为了我自己的利益。可行的计划是积极的一步,但不是真正的游戏。
是时候变得现实了。
我们的游戏需要显示分数。
PSX 并没有真正为您提供太多文本渲染方式。有一个调试字体(如上所示),但它非常基本 - 用于开发,仅此而已。
相反,我们需要创建字体纹理,并使用它来对四边形进行蒙皮。我使用 https://www.piskelapp.com/ 创建了一个等宽字体,并将其导出为透明 PNG:
PSX 纹理以称为 TIM 的格式存储。每个 TIM 文件包含:
由于纹理的 VRAM 位置已“烘焙”到 TIM 文件中,因此您需要一个工具来管理纹理位置。我为此推荐 https://github.com/Lameguy64/TIMedit 。
从那里我们只有一个函数来给一堆四边形蒙皮,UV 偏移量基于每个 ASCII 值。
我们需要一个空间来容纳各个部件。使用无聊的白色矩形来实现这一点很容易,但我想要一些感觉更……PlayStation的东西
我们的用户界面正在整合在一起。那碎片呢?
现在是一些重要的视觉设计。理想情况下,每块砖块在视觉上都应具有鲜明的阴影边缘。我们用两个三角形和一个四边形来做到这一点:
在 1 倍原始分辨率下,效果会不太清晰,但看起来仍然漂亮且厚实:
在我的游戏的第一个原型中,我实现了一个完整的简单旋转系统,该系统实际上会将块在中心点上翻转 90 度。事实证明,这实际上并不是一个很好的方法,因为它会导致块“摇摆”,在旋转时上下移动:
相反,旋转被硬编码为“好”而不是“准确”。片断是在 4x4 单元格的网格内定义的,每个单元格都可以填充或不填充。有 4 个轮换。因此:旋转只能是四个 16 位数字的数组。看起来像这样:
/**
* Example: T block
*
* As a grid:
*
* .X.. -> 0100
* XXX. -> 1110
* .... -> 0000
* .... -> 0000
*
* binary = 0b0100111000000000
* hexadecimal = 0x4E00
*
*/
typedef int16_t ShapeBits ;
static ShapeBits shapeHexes [ 8 ][ 4 ] = {
{ 0 }, // NONE
{ 0x0F00 , 0x4444 , 0x0F00 , 0x4444 }, // I
{ 0xE200 , 0x44C0 , 0x8E00 , 0xC880 }, // J
{ 0xE800 , 0xC440 , 0x2E00 , 0x88C0 }, // L
{ 0xCC00 , 0xCC00 , 0xCC00 , 0xCC00 }, // O
{ 0x6C00 , 0x8C40 , 0x6C00 , 0x8C40 }, // S
{ 0x0E40 , 0x4C40 , 0x4E00 , 0x4640 }, // T
{ 0x4C80 , 0xC600 , 0x4C80 , 0xC600 }, // Z
};
提取单元格值只是简单位掩码的一个例子:
#define GRID_BIT_OFFSET 0x8000;
int blocks_getShapeBit ( ShapeBits s , int y , int x ) {
int mask = GRID_BIT_OFFSET >> (( y * 4 ) + x );
return s & mask ;
}
现在事情正以势头聚集在一起。
正是在这一点上,我遇到了一个障碍:随机化。为了使游戏值得玩,棋子必须以随机方式出现,但随机化对于计算机来说很难。在我的 MacOS 版本上,我能够使用系统时钟“播种”随机数生成器,但 PSX 没有内部时钟。
相反,许多游戏采取的解决方案是让玩家创建种子。游戏会显示一个初始屏幕或标题屏幕,其中包含“按开始开始”等文本,然后从按下按钮开始计时以创建种子。
我通过声明一些二进制编码的int32
创建了一个“图形”,其中每个1
位都是一排砖块中的一个“像素”:
我想要的是线条逐渐消失在视野中。首先,我需要一个函数来有效地“跟踪”它被调用的次数。 C 使用static
关键字使这变得容易 - 如果在函数内部使用,则在下一次调用时将重复使用相同的内存地址和内容。
然后在同一个函数中是一个循环,它遍历“网格”的 x/y 值,并决定是否有足够的刻度来显示“像素”:
void ui_renderTitleScreen () {
static int32_t titleTimer = 0 ;
titleTimer ++ ;
// For every 2 times (2 frames) this function is called, ticks increases by 1
int32_t ticks = titleTimer / 2 ;
// Dissolve-in the title blocks
for ( int y = 0 ; y < 5 ; y ++ ) {
for ( int x = 0 ; x < 22 ; x ++ ) {
int matrixPosition = ( y * 22 ) + x ;
if ( matrixPosition > ticks ) {
break ; // because this 'pixel' of the display is not to be displayed yet
}
int32_t titleLine = titlePattern [ y ];
int32_t bitMask = titleMask >> x ;
if ( titleLine & bitMask ) { // there is a 'pixel' at this location to show
ui_renderBlock ( /* skip boring details */ );
}
}
}
}
我们快到了。
经典 PSX 游戏分两个阶段启动:首先是索尼计算机娱乐屏幕,然后是 PSX 徽标。但如果我们编译并运行hello-psx
项目,则不会。第二个屏幕是黑色的。这是为什么?
好吧,SCE 启动声来自 BIOS,PSX 启动声音也是如此,但著名的徽标实际上是光盘许可证数据的一部分。它的作用就像“真实性印章”——因此任何盗版游戏的人都在复制索尼的知识产权以及发行商的知识产权。这为索尼提供了更多打击软件盗版的法律手段。
如果我们希望我们的游戏显示徽标,我们需要提供从 ISO 中提取的许可证文件,但为了版权,我们必须.gitignore
它。
< license file = " ${PROJECT_SOURCE_DIR}/license_data.dat " />
好的。现在我们准备好了。
这一切都始于一次冲动购买,我的黑色 Yaroze PlayStation。具有讽刺意味的是,它实际上不会玩我的游戏,因为它仍然拥有反盗版硬件。我不喜欢在如此无价的 PSX 历史上安装模组芯片 - 不以我的焊接技术。
相反,我不得不找到一台改装过的灰色 PlayStation,它的驱动器仍然不错。我认为我的项目的重点是编写一款真正的PlayStation 游戏,这意味着使用真正的PlayStation。
我还必须找到合适的媒体。 PSX 激光非常挑剔,现代 CD-R 的反射率往往比压制光盘低得多。我第一次尝试杂货故事 CD 是浪费时间,在大约两周的时间里,我制作了很多杯垫。
这是一个黑暗的时刻。难道我已经走了这么远,却无法刻录 CD吗?
几周后,我得到了一些特殊的 JVC Taiyo Yuden 股票。据我所知,这些非常专业,通常用于工业应用。我刻录了盘片中的第一张光盘,并预料到最坏的情况。
这是关键时刻:
PlayStation 启动序列从我的微型显示器扬声器中传出,经典的“PS”标志以充满活力的 640 x 480 分辨率在屏幕上飞溅。 BIOS 显然在该光盘上发现了一些东西,但此后很多东西可能会失败。屏幕变黑,我竖起耳朵聆听驱动器错误的“咔嗒”声。
相反,一个又一个的彩色小方块开始从黑暗中闪烁。他们一行一行拼出一个单词: NOTRIS
。然后: PRESS START TO BEGIN
。文字吸引了我。接下来会发生什么?
当然是俄罗斯方块游戏。我为什么感到惊讶?用 C 语言编写你自己的 PlayStation 游戏实际上非常简单:所需要的就是不犯任何错误。这就是为你计算的,尤其是低级的东西。它又硬又锋利,而且很漂亮。现代计算的优势有所减弱,但本质并没有改变。
我们这些热爱计算机的人需要有一些轻微的问题,一种对我们理性的非理性,一种否认我们眼睛和耳朵的所有证据的方式,即敌对的硅盒子是死的和不屈服的。时尚是通过狡猾的机器创造出它存在的幻觉。