Nortis(以前稱為 Notris)是一款自製 PSX 遊戲,使用現代工具以 C 語言編寫。它完全可以在原始硬體上播放,並由 PSNoobSDK 提供支援。
在此處查看 PSX 程式碼庫。
去年,我得到了一台罕見的黑色 PlayStation 1。這是索尼一個特殊項目的一部分,旨在讓愛好者和學生進入遊戲行業。
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 函數教科書。
原則ccpsx
,PSX 開發人員可以完全使用 C 語言工作。這支援一系列優化,例如程式碼內聯和循環展開,儘管效能關鍵部分仍然需要手動優化組裝。
(您可以在這些 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 遊戲實際上非常簡單:所需要的就是不犯任何錯誤。這就是為你計算的,尤其是低級的東西。它又硬又鋒利,而且很漂亮。現代計算的優勢減弱,但本質並沒有改變。
我們這些熱愛電腦的人需要有一些輕微的問題,一種對我們理性的非理性,一種否認我們眼睛和耳朵的所有證據的方式,即敵對的矽盒子是死的和不屈服的。時尚是透過狡猾的機器創造出它存在的幻覺。