Nortis (ранее Notris) — это самодельная игра для PSX, написанная на языке C с использованием современных инструментов. В него полностью можно играть на оригинальном оборудовании, и он работает на PSNoobSDK.
Посмотреть кодовую базу PSX можно здесь.
В прошлом году мне в руки попала редкая черная PlayStation 1. Она называется Net Yaroze и представляет собой специальную консоль, на которой можно играть как в домашние игры, так и в обычные игры для PSX. Это была часть специального проекта Sony, призванного привлечь любителей и студентов в игровую индустрию.
Игры Yaroze были очень ограничены, поскольку Sony не хотела, чтобы кодеры для спальни конкурировали с коммерческими разработчиками. Их можно было воспроизвести только на других Ярозах или на специальных демо-дисках. Они должны были полностью поместиться в оперативной памяти системы без доступа к CD-ROM. Несмотря на эти ограничения, Yaroze создал страстное сообщество инди-разработчиков.
И теперь у меня было свое. Это заставило меня задуматься: каково было на самом деле писать игру для PlayStation?
Речь идет о том, как я сам написал простую домашнюю игру для PSX, используя версию библиотек с открытым исходным кодом, но все еще работающую на оригинальном оборудовании и написанную на классическом C.
Пропустить этот раздел
Игры для PSX обычно писались на языке C на рабочих станциях с Windows 9X. Официальный комплект разработки представлял собой пару карт расширения ISA, которые вставлялись в обычную материнскую плату IBM PC и содержали весь набор микросхем системы PSX, видеовыход и дополнительную оперативную память (8 МБ вместо 2 МБ). Это обеспечило вывод TTY и отладчика на хост-компьютер.
Возможно, вы слышали о синих PlayStation. Они предназначались для контроля качества, а не для разработки, и идентичны розничным устройствам, за исключением того, что они могут воспроизводить записанные компакт-диски. Однако по крайней мере одна компания продала специальный аддон для конвертации их в комплекты для разработки:
Дизайн был очень удобен для разработчиков. Вы можете играть в свою игру на CRT с обычными контроллерами, проходя через точки останова GDB на своем ПК с Windows 95, пролистывая толстый учебник функций C SDK.
В принципе, разработчик PSX мог полностью работать на C. SDK включал набор библиотек C под названием PSY-Q и программу-компилятор ccpsx
, которая на самом деле была просто интерфейсом над GCC. Это поддерживало ряд оптимизаций, таких как встраивание кода и развертывание цикла, хотя критические для производительности разделы по-прежнему требовали ручной оптимизации сборки.
(Вы можете прочитать об этой оптимизации на слайдах конференции SCEE).
C++ поддерживался ccpsx
, но имел репутацию создателя «раздутого» кода, а также более медленного времени компиляции. На самом деле C был языком разработки PSX, но в некоторых проектах поверх базового движка использовались языки динамических сценариев. Например, Metal Gear Solid использовал TCL для написания сценариев уровней; а игры Final Fantasy пошли еще дальше и реализовали свои собственные языки байт-кода для сражений, полевых систем и систем мини-игр. (Подробнее об этом можно узнать здесь).
( Для дальнейшего чтения посетите https://www.retroreversing.com/official-playStation-devkit )
Пропустить этот раздел
Но я подошел к этому с совершенно другой точки зрения: инженер-программист в 2024 году, который в основном работал над веб-приложениями. Мой профессиональный опыт почти исключительно связан с языками высокого уровня, такими как JavaScript и Haskell; Я немного поработал с OpenGL и C++, но современный C++ почти полностью отличается от C.
Я знал, что PSX SDK существуют для таких языков, как Rust, но мне хотелось ощутить вкус «настоящего» программирования для PSX, как это делалось еще в 90-х. Так что это будут современные наборы инструментов и библиотеки с открытым исходным кодом, но полностью на C.
Игра должна была быть чем-то двухмерным, прототип которого можно было бы создать за пару дней. Я остановился на клоне Тетриса — я решил, что он будет достаточно сложным, чтобы испытать то, что я хотел.
Первым шагом было создание прототипа по знакомой технологии. Это позволило бы мне определить базовый дизайн, а затем логику можно было бы по частям перевести на C.
Для веб-разработчика наиболее очевидной технологией для прототипирования был JavaScript: он прост, лаконичен, легко отлаживается и оснащен графическим API HTML5 <canvas>
. Все сложилось очень быстро
В то же время я опасался, что более высокоуровневые функции JavaScript будет сложно портировать. Все, что использует классы или замыкания, придется полностью переписать, поэтому я старался ограничиться простым процедурным подмножеством языка.
На самом деле у меня был скрытый мотив, когда я взялся за этот проект: это был предлог, чтобы наконец выучить C. Язык занимал большое место в моем сознании, и у меня начал развиваться комплекс неполноценности из-за его незнания.
У языка C устрашающая репутация, и я опасался ужасных историй о висящих указателях, неправильном чтении и ужасных segmentation fault
. Точнее: я беспокоился, что если я попытаюсь выучить C и потерплю неудачу, то обнаружу, что на самом деле я не очень хороший программист.
Чтобы упростить задачу, я решил использовать SDL2 для обработки ввода и графики и компиляции для своей среды рабочего стола (MacOS). Это дало бы мне быстрый цикл сборки/отладки и сделало бы кривую обучения максимально мягкой.
Несмотря на мои страхи, C мне показался невероятно интересным. Очень быстро меня это «зацепило». Вы начинаете с очень простых примитивов — структур, символов, функций — и выстраиваете их в уровни абстракции, чтобы в конечном итоге оказаться на вершине всей рабочей системы.
Портирование игры заняло всего пару дней, и я был очень доволен своим первым настоящим проектом на C. И у меня не было ни одного сегфолта!
Работать с SDL было одно удовольствие, но было несколько аспектов, которые потребовали от меня динамического распределения памяти. Это было бы запрещено на PlayStation, где malloc
предоставляемый ядром PSX, не работает должным образом. А графический конвейер стал бы еще большим скачком...
Когда дело доходит до домашнего приготовления PlayStation, есть два основных варианта вашего SDK. Или:
Есть еще несколько вариантов, таких как C++ Psy-Qo , и вы даже можете отказаться от любого SDK, чтобы самостоятельно выполнять ввод-вывод с отображением в памяти - но мне не хватило смелости для этого.
Самая большая проблема с Psy-Q заключается в том, что это все еще проприетарный код Sony, даже 30 лет спустя. По закону любое домашнее пиво, созданное с его использованием, находится под угрозой. Именно это и погубило демо-версию Portal64: она статически связывала libultra
, являющуюся собственностью Nintendo N64 SDK.
Но, честно говоря, основная причина, по которой я выбрал PSNoobSDK, заключалась в том, что он очень хорошо документирован и прост в настройке. API очень похож на Psy-Q: фактически для многих функций я мог просто обратиться к печатным ссылкам, которые прилагались к моему Yaroze.
Если то, что я использую неаутентичный SDK, оскорбляет вашего пуриста PSX, не стесняйтесь с отвращением бросить чтение.
Моей первой задачей было что-то вроде hello world: два квадрата на цветном фоне. Звучит просто, правда?
Пропустить этот раздел
(*Некоторые из них упрощены. Более авторитетное руководство можно найти в руководстве PSNoobSDK)
Для начала представьте себе PSX VRAM как большой холст размером 1024 на 512 пикселей с глубиной цвета 16 бит. В общей сложности это составляет 1 мегабайт памяти, разделяемой фреймбуферами и текстурами. Мы можем выбрать разрешение выходного фреймбуфера — даже до 640x480 пикселей, если мы жадные — но больше разрешения = меньше текстур.
В большинстве игр для PSOne (и... игр в целом) используется концепция рендеринга с двойной буферизацией: пока готовится один кадр, другой отправляется на экран. Итак, нам нужно выделить два кадровых буфера:
(Теперь вы понимаете, почему 640x480 непрактично — недостаточно места для двух буферов 480p. Но этот режим МОЖЕТ использоваться для таких вещей, как логотип запуска PSX, который не требует много анимации)
Буферы (называемые поочередно средами отображения и рисования) меняются местами каждый кадр. Большинство игр для PSX нацелены на частоту 30 кадров в секунду (в Северной Америке), но фактическое прерывание VSync происходит на частоте 60 Гц. Некоторым играм удается работать со скоростью полных 60 кадров в секунду — на ум приходят Tekken 3 и Kula World (Roll Away), — но, очевидно, тогда вам нужно выполнять рендеринг в два раза быстрее. Помните, что у нас есть только 33 МГц вычислительной мощности.
Но как происходит процесс рисования? Это делается графическим процессором, но графический процессор PSX работает совсем не так, как современная видеокарта. По сути, в каждом кадре графическому процессору отправляется упорядоченный список графических «пакетов» или команд. «Нарисуйте здесь треугольник», «загрузите эту текстуру для оформления следующего квадрата» и так далее.
Графический процессор не выполняет 3D-преобразования; это работа сопроцессора GTE (Geometry Transform Engine). Команды графического процессора представляют собой чисто 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 на телевизор и получаем кадр рисования графического процессора 3.
Как уже упоминалось, графический процессор представляет собой полностью двухмерное аппаратное обеспечение, он не знает координат z в трехмерном пространстве. Не существует «z-буфера» для описания окклюзий, то есть того, какие объекты находятся впереди других. Так как же сортируются предметы перед другими?
Это работает так: таблица упорядочивания представляет собой обратную цепочку графических команд. Они проходят задом наперед для реализации алгоритма художника .
Если быть точным, таблица заказов представляет собой обратно связанный список. Каждый элемент имеет указатель на предыдущий элемент в списке, и мы добавляем примитивы, вставляя их в цепочку. Обычно OT инициализируются как фиксированный массив, где каждый элемент массива представляет «уровень» или слой на дисплее. OT могут быть вложены для реализации сложных сцен.
Следующая диаграмма помогает объяснить это (источник)
Этот подход не идеален, и иногда геометрия PSX показывает странное отсечение, поскольку каждый полигон может находиться только в одном «индексе Z» в экранном пространстве, но для большинства игр он работает достаточно хорошо. В наши дни такие ограничения считаются частью особого очарования PSX.
Пропустить этот раздел
Мы много говорили о теории – как это выглядит на практике?
В этом разделе не будет подробно рассмотрен весь код, но он даст вам представление о графических концепциях PSX. Если вы хотите увидеть полный код, перейдите по адресу hello-psx/main.c
.
В качестве альтернативы, если вы не программист, смело пропускайте эту статью. Это только для любопытных технарей.
Первое, что нам нужно, — это структуры для хранения наших буферов. У нас будет RenderContext
, который содержит два RenderBuffers
, и каждый 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
, и наоборот.
// 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... оно довольно краткое и достаточно ясно написано.
Теперь... как мы будем рисовать? Мы записываем структуры в наш буфер примитивов. Этот буфер напечатан как большой список chars
, поэтому мы приводим его к нашей структуре shape/command, а затем перемещаем указатель буфера примитивов с помощью 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-psx
вы можете видеть, что я документировал как можно больше, действительно ради собственной выгоды. Рабочая программа была позитивным шагом, но не настоящей игрой.
Пришло время стать реальностью .
Наша игра должна показывать счет.
PSX на самом деле мало что дает в плане рендеринга текста. Существует отладочный шрифт (показан выше), но он очень простой — для разработки и не более того.
Вместо этого нам нужно создать текстуру шрифта и использовать ее для оформления четырехугольников. Я создал моноширинный шрифт с помощью https://www.piskelapp.com/ и экспортировал его как прозрачный PNG:
Текстуры PSX хранятся в формате TIM. Каждый файл TIM содержит:
Поскольку расположение текстуры в VRAM «встроено» в файл TIM, вам нужен инструмент для управления расположением текстур. Для этого я рекомендую https://github.com/Lameguy64/TIMedit.
Отсюда у нас просто есть функция для скининга нескольких квадратов со смещением UV, основанным на каждом значении ASCII.
Нам нужно место, куда можно будет поместить детали. Было бы легко использовать для этого скучный белый прямоугольник, но мне хотелось чего-то большего… PlayStation
Наш пользовательский интерфейс собирается вместе. А что насчет кусочков?
Теперь поговорим о важном визуальном дизайне. В идеале каждый кирпичик должен визуально отличаться острыми, затененными краями. Проделаем это с двумя треугольниками и четырехугольником:
При исходном разрешении 1x эффект будет менее четким, но он по-прежнему выглядит красиво и объемно:
В первом прототипе моей игры я реализовал полностью наивную систему вращения, которая фактически переворачивала блок на 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 загружаются в два этапа: сначала отображается экран Sony Computer Entertainment, затем логотип PSX. Но если мы скомпилируем и запустим проект hello-psx
этого не произойдет. Второй экран просто черный. Почему это?
Что ж, заставка SCE исходит из BIOS, как и звук загрузки PSX, но знаменитый логотип на самом деле является частью данных лицензии на диск. Он действует как «печать подлинности» — поэтому любой, кто занимается пиратством игры, копирует IP-адрес Sony, а также IP-адрес издателя. Это дало Sony больше легальных инструментов для борьбы с пиратством в области программного обеспечения.
Если мы хотим, чтобы в нашей игре отображался логотип, нам нужно предоставить файл лицензии, извлеченный из ISO, но ради защиты авторских прав мы должны его .gitignore
.
< license file = " ${PROJECT_SOURCE_DIR}/license_data.dat " />
Хорошо. Теперь мы готовы.
Все началось с импульсивной покупки моей черной PlayStation Yaroze. По иронии судьбы, он на самом деле не играл бы в мою игру, поскольку у него все еще было антипиратское оборудование. Мне не хотелось устанавливать модчип на такую бесценную часть истории PSX — не с моими навыками пайки.
Вместо этого мне пришлось найти модифицированную серую PlayStation, у которой все еще был приличный привод. Я решил, что целью моего проекта было написать настоящую игру для PlayStation, а это означало использование настоящей PlayStation.
Мне также нужно было найти подходящие средства массовой информации. Лазер PSX довольно требователен, и современные компакт-диски CD-R, как правило, гораздо менее отражают свет, чем прессованные диски. Мои первые попытки создать компакт-диски с рассказами о продуктовых магазинах оказались пустой тратой времени, и примерно за две недели я создал множество подставок.
Это был мрачный момент. Неужели я проделал весь этот путь только для того, чтобы не записать компакт-диск ?
Через несколько недель я получил в свои руки специальные акции JVC Taiyo Yuden. Судя по тому, что я прочитал, они были весьма специализированными и обычно использовались в промышленных целях. Я записал первый диск на диске и ожидал худшего.
Это был момент истины:
Из крошечных динамиков моего монитора раздался звук загрузки PlayStation, и классический логотип «PS» расплескался по экрану с ярким разрешением 640х480. BIOS явно что-то нашел на этом диске, но после этого многое могло выйти из строя. Экран потемнел, и я напряг слух, услышав характерный щелчок-щелк-щелканье ошибки привода.
Вместо этого один за другим из темноты начали мигать маленькие цветные квадратики. Строку за строкой они выписали слово: NOTRIS
. Затем: PRESS START TO BEGIN
. Текст манил меня. Что произойдет дальше?
Конечно, игра в тетрис. Почему я был удивлен? Написать собственную игру для PlayStation на языке C на самом деле очень просто: все, что для этого требуется, — это не допускать никаких ошибок . Это вычисления для вас, особенно низкоуровневые. Он твердый, острый и красивый. Современные компьютерные технологии стали более мягкими, но суть не изменилась.
У тех из нас, кто любит компьютеры, должно быть что-то не так с нами, иррациональность нашей рациональности, способ отрицать все свидетельства наших глаз и ушей о том, что враждебный кремниевый ящик мертв и непреклонен. И с помощью хитроумных механизмов создаем иллюзию того, что оно живет.