Консоль Dendy в первую очередь ассоциируется с относительно простыми играми (Super Mario Bros, Duck Hunt, Battle City и т. д.), которые обычно не требуют сложных расчётов и обходятся целочисленной математикой. Но как только нужно сделать трёхмерную графику или сложную физику, сразу появляется потребность в точных вычислениях и дробных числах.
Самым простым и быстрым способом программного представления дробей являются числа с фиксированной запятой (Fixed‑point числа). О реализации такой арифметики в NES/Dendy мы и поговорим.
Современные процессоры имеют аппаратные математически модули для работы дробными числами (обычно реализуется математика для чисел с плавающей запятой), но консоль NES такого модуля не имеет и математику дробей можно реализовать только программно.
Кроме того, нужно помнить, что NES имеет очень ограниченные аппаратные ресурсы (8-битный процессор Ricoh с тактовой частотой 1,79 МГц, 2 КБ RAM и 32 КБ ROM). Поэтому очень важно оптимизировать вычисления.
Числа с фиксированной запятой
Числа с фиксированной запятой представляют собой формат чисел, в котором фиксированное количество битов отводится под целую и дробную части. Это позволяет эффективно выполнять арифметические операции на системах с ограниченными ресурсами.
Основной вопрос при использовании чисел с фиксированной запятой заключается в выборе размерности целой и дробной части. Конечный выбор зависит от конкретной задачи.
Давайте рассмотрим влияние размерности на точность числа. Возьмём формат, в котором для целой и дробной части выделено по 8 битов. Назовём такой формат UFIX8_8. И пусть число будет беззнаковое (со знаковыми немного сложнее).
Целая часть хранится «как есть», то есть это просто целое число от 0 до 255. А дробная часть представляет собой множитель для дроби 1/256. В виде формулы это выглядит вот так (далее будем называть целую часть INT, а дробную — FRAC):
Из формулы очевидно, что точность дробной части составляет 0,00 390 625, то есть все значения дробной части кратны этому числу. Это значит, что число в промежутке между (0 — 0,0039) мы никак не можем получить. На практике такой точности часто достаточно, но погрешность всегда нужно учитывать и держать в уме во время разработки программ.
Теперь рассмотрим, как такое число будет выглядеть в памяти. Пусть целая часть равна 145, а дробная — 231. Для процессора это число будет выглядеть так: 1001 0001.1110 0111. Посчитаем его десятичное значение:
Это значит, что число 1001 0001.1110 0111 в формате UFIX8_8 равняется числу 145,90 234 375.
Теперь рассмотрим формат UFIX16_16 (два байта на целую часть и два байта на дробную). Целая часть может хранить значение от 0 до 65 536 (2^16), а дробная единица будет равна числу:
Всё просто.
Стоит отметить, что для чисел с фиксированной точкой существует и десятичный формат хранения дробной части. Он ещё проще в понимании.
В десятичном формате для целой и дробной части выделяются фиксированное количество десятичных разрядов. Например, если мы хотим хранить 5 знаков в целой части и 3 знака после запятой, то нам нужно будет хранить целые числа в диапазоне от 0 до 99 999 999. Для хранения таких чисел потребуется целых 4 байта!
Например, десятичная дробь 12 324,456 будет храниться как обычное целое число 12 324 456. Просто нужно держать в уме, что младшие три десятичных разряда являются дробной частью.
Кроме не самого оптимального использования памяти в таком формате, умножение и деление в нём нельзя проводить сдвигом, нельзя без операции деления получить дробную часть и так далее. Но такой формат часто используют в банковских задачах, так как он даёт точное вычисление дробной части.
Вычисления десятичных и двоичных дробей реализуются примерно одинаково, но далее мы будем говорить только о двоичных дробях.
Реализация арифметики чисел с фиксированной запятой
Я разрабатываю игры для Dendy на языке Си и использую компилятор CC65, поэтому весь представленный код написан под него. Но эти примеры должны работать и на других компиляторах Си, которые поддерживают стандарт С99. Подробнее об инструментах для разработки игр я рассказывал в своей статье про портирование Dangerous Dave для NES, поэтому останавливаться на этой теме не будем.
Реализацию базовой арифметики следует начинать с определения размерности чисел. Весь следующий код будет ориентироваться на числа в формате FIX8_8. Я выбрал его из‑за ускорения вычислений и отсутствия потребности в высокоточных вычислениях. Ведь если выбрать формат FIX16_8 (два байта на целую и один на дробную часть), то любое действие будет требовать дополнительных операций копирования байтов памяти, так как процессор у нас 8-битный и оперирует одиночным байтами.
Определим наши типы на языке Си:
Я разрабатываю игры для Dendy на языке Си и использую компилятор CC65, поэтому весь представленный код написан под него. Но эти примеры должны работать и на других компиляторах Си, которые поддерживают стандарт С99. Подробнее об инструментах для разработки игр я рассказывал в своей статье про портирование Dangerous Dave для NES, поэтому останавливаться на этой теме не будем.
Реализацию базовой арифметики следует начинать с определения размерности чисел. Весь следующий код будет ориентироваться на числа в формате FIX8_8. Я выбрал его из-за ускорения вычислений и отсутствия потребности в высокоточных вычислениях. Ведь если выбрать формат FIX16_8 (два байта на целую и один на дробную часть), то любое действие будет требовать дополнительных операций копирования байтов памяти, так как процессор у нас 8-битный и оперирует одиночным байтами.
Определим наши типы на языке Си:
// Беззнаковое число с фиксированной точкой 16 битов (младшие 8 битов на дробную часть)
typedef uint16_t ufix8_8;
// Знаковое число с фиксированной точкой 16 битов (младшие 8 битов на дробную часть)
typedef int16_t fix8_8;
// Беззнаковое число с фиксированной точкой 32 бита (младшие 8 битов на дробную часть)
typedef uint32_t ufix24_8;
typedef int32_t fix24_8;
И ещё давайте определим макросы для удобного ввода дробей и лучшей читаемости кода:
// Возвращает число с фиксированной запятой fix8_8
// GET_DEC(3,45) - вернёт (3.045)!
#define GET_DEC(INT,FRAC) \
((INT << 8) | ( (FRAC * 256UL) / 1000UL ) )
// Задаёт и возвращает число с фиксированной запятой в двоичном виде
// инт + (frac/256) - его десятичное значение
// Дробная часть хранится просто в виде frac
#define GET_BIN(INT,FRAC) \
((INT << 8) | FRAC)
GET_DEC() в качестве аргумента принимает число в виде десятичной дроби (первый аргумент — целая часть, а второй аргумент — трёхразрядная дробная часть), а возвращает число в правильном двоичном формате. Код простой, дополнительных уточнений не требует. Но обратите внимание, что при вычислении конечного значения константы, компилятор округлит его до целых. И конечная дробь будет кратна числу 0,00390625. Поэтому реальное значение переменной не будет совпадать с тем, которое вы указали, оно будет иметь некоторую погрешность.
GET_BIN() просто реализует более удобный ввод в двоичном формате.
Это всё можно было реализовать в виде функций (CC65 не поддерживает inline-функции), но они работали бы медленнее, поэтому вся библиотека построена на макросах.
GET_DEC()
в качестве аргумента принимает число в виде десятичной дроби (первый аргумент — целая часть, а второй аргумент — трёхразрядная дробная часть), а возвращает число в правильном двоичном формате. Код простой, дополнительных уточнений не требует. Но обратите внимание, что при вычислении конечного значения константы, компилятор округлит его до целых. И конечная дробь будет кратна числу 0,00 390 625. Поэтому реальное значение переменной не будет совпадать с тем, которое вы указали, оно будет иметь некоторую погрешность.
GET_BIN()
просто реализует более удобный ввод в двоичном формате.
Это всё можно было реализовать в виде функций (CC65 не поддерживает inline‑функции), но они работали бы медленнее, поэтому вся библиотека построена на макросах.
Сложение и вычитание
Теперь можно перейти к математическим операциям. Начнём с вычитания и сложения. Для этих операций никакие дополнительные действия не нужны, числа можно складывать и вычитать «как есть». Пример:
ufix8_8 u_a;
ufix8_8 u_b;
ufix8_8 u_c;
// Ввод значений
u_a = GET_DEC (2,933);
u_b = GET_DEC (23,230);
// Выводим результат
value_32bit = u_a + u_b;
fix_point32_number_to_str9_signed ();
Представленный код реализует сложение двух чисел: 2,933 + 23,230. Мы ожидаем получить в результате 26,163. Но результат будет такой (тут накопились ошибки от округления при вводе через макрос):
Ошибка составила 0,007. Если бы мы использовали ввод в двоичном формате через GET_BIN()
, то ошибки не было бы.
Для знаковых чисел всё то же самое, но нужно помнить, что диапазон целой части уменьшается в два раза, так как старший бит переменной хранит информацию о знаке. И, к счастью, компилятор поддерживает работу со знаковыми числами, поэтому велосипед изобретать не пришлось. Пример работы со знаковыми числами:
// Ввод значений
a = GET_DEC (2,933);
b = -1 * GET_DEC (23,230);
// Выводим результат
value_32bit = a + b;
fix_point32_number_to_str9_signed ();
Результат:
Всё работает.
Вычитание работает точно так же. Если кому‑то интересно, то вот так выглядит строка со сложением на ассемблере консоли:
; value_32bit = a + b;
lda _a
clc
adc _b
pha
lda _a+1
adc _b+1
tax
pla
jsr axlong
sta _value_32bit
stx _value_32bit+1
ldy sreg
sty _value_32bit+2
ldy sreg+1
sty _value_32bit+3
Умножение и деление
С умножением и делением всё немного сложнее, но ничего грандиозного.
Умножение требует сдвига результата вправо на количество битов дробной части, чтобы сохранить правильный масштаб.
Деление, наоборот, требует предварительного сдвига числителя влево перед выполнением операции деления.
Начнем с реализации умножения:
// Возвращает адрес первого байта числа
#define GET_BYTE(X) ((uint8_t*)(&X))
// Реализует сдвиг вправо на 8 битов через копирование байтов
// Для 32-битных чисел
#define SHIFT_RIGHT_BY_8_BITS(N_32BIT) \
GET_BYTE(N_32BIT) [0] = GET_BYTE(N_32BIT) [1]; \
GET_BYTE(N_32BIT) [1] = GET_BYTE(N_32BIT) [2]; \
GET_BYTE(N_32BIT) [2] = GET_BYTE(N_32BIT) [3]; \
GET_BYTE(N_32BIT) [3] = 0
// Макрос беззнакового умножения A = X * Y
// A - ufix24_8
// X, Y - ufix8_8 или ufix24_8
#define MUL_UFIX(X, Y, A) \
A = (ufix24_8)X * (ufix24_8)Y; \
SHIFT_RIGHT_BY_8_BITS (A)
// Тут стандартный сдвиг, так как нужно учитывать знаковый бит
#define MUL_FIX(X, Y, A) \
A = (fix24_8)X * (fix24_8)Y; \
A >>= 8
Знаковое умножение и деление отличаются только типом, к которому приводятся аргументы.
Сдвиг в виде макроса реализован для оптимизации кода. Если использовать оператор сдвига, то компилятор даёт не самый оптимальный код. Макрос GET_BYTE(X)
выглядит жутко, но это самый оптимальный способ обратиться к байту многобайтовой переменной. Чем отличается стандартный сдвиг и мой вариант, можно посмотреть в следующем ассемблерном коде, который сгенерировал компилятор:
Скрытый текст
; Стандартный оператор сдвига
; MUL_UFIX (u_a, u_b, u_c);
lda _u_a
ldx _u_a+1
ldy #$00
jsr push0ax
lda _u_b
ldx _u_b+1
; tosumul0ax — это стандартная функция умножения чисел
; Умножение реализуется программно, так как NES не имеет инструкции умножения
jsr tosumul0ax
sta _u_c
stx _u_c+1
ldy sreg
sty _u_c+2
ldy sreg+1
sty _u_c+3
lda _u_c+3
; Сдвиг вправо на 8 битов _u_c
sta sreg+1
lda _u_c+2
sta sreg
ldx _u_c+1
stx _u_c
ldx sreg
ldy sreg+1
sty sreg
ldy #$00
sty sreg+1
stx _u_c+1
ldy sreg
sty _u_c+2
ldy sreg+1
sty _u_c+3
// Моя реализация сдвига
; MUL_UFIX (u_a, u_b, u_c);
lda _u_a
ldx _u_a+1
ldy #$00
jsr push0ax
lda _u_b
ldx _u_b+1
jsr tosumul0ax
; Сохранение значения умножения в _u_c
sta _u_c
stx _u_c+1
ldy sreg
sty _u_c+2
ldy sreg+1
sty _u_c+3
; Сдвиг вправо на 8 битов _u_c
lda _u_c+1
sta _u_c
lda _u_c+2
sta _u_c+1
lda _u_c+3
sta _u_c+2
lda #$00
sta _u_c+3
Стандартная функция tosumul0ax, которая реализует умножение чисел, — не самый оптимальный вариант для реализации умножения чисел с фиксированной запятой. Её надо переписывать и оптимизировать под формат FIX8_8 (там как минимум можно убрать лишние копирования), но этим мы займёмся как‑нибудь в другой раз. Исходный код tosumul0ax
выглядит вот так (страшно, очень страшно, мы не знаем, что это такое…):
Скрытый текст
; Ullrich von Bassewitz, 13.08.1998
; Christian Krueger, 11-Mar-2017, added 65SC02 optimization
;
; CC65 runtime: multiplication for long (unsigned) ints
;
.export tosumul0ax, tosumuleax, tosmul0ax, tosmuleax
.import addysp1
.importzp sp, sreg, tmp1, tmp2, tmp3, tmp4, ptr1, ptr3, ptr4
.macpack cpu
tosmul0ax:
tosumul0ax:
.if (.cpu .bitand ::CPU_ISET_65SC02)
stz sreg
stz sreg+1
.else
ldy #$00
sty sreg
sty sreg+1
.endif
tosmuleax:
tosumuleax:
mul32: sta ptr1
stx ptr1+1 ; op2 now in ptr1/sreg
.if (.cpu .bitand ::CPU_ISET_65SC02)
lda (sp)
ldy #1
.else
ldy #0
lda (sp),y
iny
.endif
sta ptr3
lda (sp),y
sta ptr3+1
iny
lda (sp),y
sta ptr4
iny
lda (sp),y
sta ptr4+1 ; op1 in pre3/ptr4
jsr addysp1 ; Drop TOS
; Do (ptr1:sreg)*(ptr3:ptr4) --> EAX.
lda #0
sta tmp4
sta tmp3
sta tmp2
ldy #32
L0: lsr tmp4
ror tmp3
ror tmp2
ror a
ror sreg+1
ror sreg
ror ptr1+1
ror ptr1
bcc L1
clc
adc ptr3
tax
lda ptr3+1
adc tmp2
sta tmp2
lda ptr4
adc tmp3
sta tmp3
lda ptr4+1
adc tmp4
sta tmp4
txa
L1: dey
bpl L0
lda ptr1 ; Load the low result word
ldx ptr1+1
rts
Давайте попробуем что‑нибудь умножить, используя наши макросы. Перемножим те же самые числа (но одно сделаем отрицательным):
a = -1 * GET_DEC (2,933);
b = GET_DEC (23,230);
MUL_FIX (a, b, c);
value_32bit = c;
fix_point32_number_to_str9_signed ();
Результат вычислений:
А правильный результат должен быть -68,13 359. Тут накопилась уже значительная погрешность, но если вводить числа в двоичном виде, она будет не такая большая. Давайте попробуем двоичный ввод:
a = -1 * GET_BIN (2,240);
b = GET_BIN (23,26);
MUL_FIX (a, b, c);
value_32bit = c;
fix_point32_number_to_str9_signed ();
Результат:
Правильный результат должен быть -1 * (2 + 240/256) * (23 + 26/256) = -67,8 608 398 438. Погрешность уже около 0,003, как и должно быть. Макросы работают корректно.
А вот и макросы для деления:
// Беззнаковое деление - С = X / Y
// A - uint32_t
// X, Y - uint16_t or uint32_t
#define DIV_UFIX(X, Y, A) \
GET_BYTE(A) [0] = 0; \
GET_BYTE(A) [1] = GET_BYTE(X) [0]; \
GET_BYTE(A) [2] = GET_BYTE(X) [1]; \
GET_BYTE(A) [3] = 0; \
A /= (ufix8_8)Y
// Знаковое деление
// Проверка нужна для отслеживания знака числа
#define DIV_FIX(X, Y, A) \
GET_BYTE(A) [0] = 0; \
GET_BYTE(A) [1] = GET_BYTE(X) [0]; \
GET_BYTE(A) [2] = GET_BYTE(X) [1]; \
if (GET_BYTE(X) [1] & 0x80) \
GET_BYTE(A) [3] = 0xFF; \
else \
GET_BYTE(A) [3] = 0; \
A /= (fix24_8)Y
// Применяем макросы
a = -1 * GET_BIN (2,240);
b = GET_BIN (23,26);
DIV_FIX (a, b, c);
value_32bit = c;
fix_point32_number_to_str9_signed ();
Результат:
Правильное значение: -1 * (2 + 240/256) / (23 + 26/256) = -0,12 715 590 125. Погрешность снова незначительная, всё работает.
Тригонометрия на NES
В предисловии я говорил о трёхмерной графике, но какая может быть трёхмерная графика без тригонометрии? Правильно, никакая. Поэтому давайте реализуем базовые тригонометрические функции. Начнём с введения своего формата данных. Назовём наш тип данных двоичными градусами, то есть единица двоичного градуса равняется 1/256 от 90 градусов (0,3 515 625 градуса). Диапазон до 90 градусов выбран потому, что его достаточно для получения значений функций во всех прочих углах. Для примера будем вычислять косинусы.Через них можно получить значения остальных функций (вспоминаем школьную тригонометрию).
Есть два пути реализации тригонометрии: таблица с готовыми значениями функции и приближённое вычисление с помощью математических методов (ряд Тейлора, метод Ньютона, многочлены Чебышева и так далее). В реальном проекте имеет смысл использовать табличные вычисления, так как численные методы очень медленные. Генерировал я таблицу с помощью следующего скрипта на Python:
import math
# Массив дробной части
for x in range (256):
print (str ( round( math.cos( (math.pi/180) * (x/256) * 90 )*256 ) ) + ',' + ' // cos (' + str ((x/256) * 90) + ')' + ' ' + str (x) )
Так как косинус не может быть больше единицы, его значения отлично подходят под формат двоичных дробей. Для всех 256 значений достаточно 256 байтов ROM-памяти. Результат генерации таблицы значений:
const uint8_t cosines[] = {
255, // cos (0.0)
255, // cos (0.3515625)
255, // cos (0.703125)
255, // cos (1.0546875)
255, // cos (1.40625)
…..
11, // cos (87.5390625)
9, // cos (87.890625)
8, // cos (88.2421875)
6, // cos (88.59375)
5, // cos (88.9453125)
3, // cos (89.296875)
2, // cos (89.6484375)
};
Полная версия таблицы лежит в репозитории.
Рассмотрим пример использования таблицы косинусов:
value_32bit = cosines [128];
fix_point32_number_to_str9_signed ();
Результат:
Dendy правильно посчитала косинус 45 градусов. Отлично!
Для улучшения читаемости кода я написал несколько макросов:
// Переводит обычные градусы в двоичные градусы
// INT - целая часть
// FRAC - дробная (2 разряда!)
// FRAC = 3 - это 0,03
#define DEGREE(INT, FRAC) \
( ( (INT*100 + FRAC) * 256UL ) / 9000UL )
// Переводит обычные радианы в двоичные градусы
// Аналогично как и для градусов
#define RAD(INT, FRAC) \
( ( (INT*100 + FRAC) * 256UL ) / 157UL )
// Вычисляет косинус в градусах до 90 градусов
// Только до 90 градусов
#define COS_D(INT, FRAC) \
cosines [DEGREE (INT, FRAC)]
// Вычисляет косинус в радианах до 90 градусов
// Только до 90 градусов
#define COS_R(INT, FRAC) \
cosines [RAD (INT, FRAC)]
// Вычисляет косинус двоичных градусов
// 1 двоичный градус = 90/256 обычных градусов
// Только до 90 градусов
#define COS_B(B) \
cosines [B]
Аналогично можно реализовать все остальные тригонометрические функции или вычислять их через косинус. Тут нужно исходить из конкретной задачи, какие функции будут нужны. При разработке игр для древних устройств лишний код в программе не нужен.
Но в следующих обновлениях библиотеки я добавлю все основные функции в виде отдельных файлов, чтоб их можно было выборочно включать в проект. Вся библиотека будет храниться в открытом доступе.
Корни
«А как же квадратные корни?», — спросите вы. Их я тоже реализовал, куда уж без них в математической библиотеке. Корни вычисляются аналогично косинусам. Для использования табличного метода требовалось сгенерировать искомую таблицу. Она представляет собой два массива по 256 элементов. Первый массив хранит целую часть, а второй — дробную. Генерировал массивы снова с помощью Python:
#Генерация таблицы корней для чисел от 0 до 255
import math
for x in range (256):
temp = math.sqrt (x)
# Вывод целой части
print ( str ( int (temp) ) + ' // sqrt (' + str (x)+") = " + str (round (temp, 3)) + ",")
# Вывод дробной части в виде двоичной дроби (x/256)
print ( str ( int (round ( (temp-int(temp))*256, 0)) ) + ' // sqrt (' + str (x)+") = " + str (round (temp, 3)) + ",")
Выглядит результат генерации так:
// Целая часть результата вычисления корня
const uint8_t sqrt_int [256] = {
0, // sqrt (0) = 0.0
1, // sqrt (1) = 1.0
1, // sqrt (2) = 1.414
1, // sqrt (3) = 1.732
2, // sqrt (4) = 2.0
2, // sqrt (5) = 2.23
….
};
// Дробная часть
const uint8_t sqrt_frac [256] = {
0, // sqrt (0) = 0.0
0, // sqrt (1) = 1.0
106, // sqrt (2) = 1.414
187, // sqrt (3) = 1.732
0, // sqrt (4) = 2.0
60, // sqrt (5) = 2.236
….
};
// Квадратный корень положительного целого числа от 0 до 255
// Аргумент может быть переменной или целочисленной константой
// Результат сохраняется в fix8_8
#define SQRT(X, fix8_8) \
GET_BYTE(fix8_8) [0] = sqrt_frac [X]; \
GET_BYTE(fix8_8) [1] = sqrt_int [X]
Пример использования:
// Вычисляем корень числа 47
SQRT(47, c);
value_32bit = c;
fix_point32_number_to_str9_signed ();
Результат:
А правильное значение приблизительно равно 6,8 556 546. Похоже на правду, макрос работает.
Теперь мы готовы к любым вычислительным задачам! Но пишите в комментариях какие ещё математически функции стоит реализовать.
Вычисления косинуса через ряд Тейлора
На сладкое я оставил реализацию вычисления косинуса через ряд Тейлора как способ проверки представленной математической библиотеки. Код получился довольно простой, но показательный:
// Вычисляет косинус через три члена ряда тейлора
// Входное значение задается через value_16bit в радианах
// Возле 0 и Пи считает плохо
void taylor_cos (void) {
SQUARE (value_16bit, u_c);
term_1 = u_c; // X^2
SQUARE (term_1, u_c); // X^4
term_2 = u_c; // X^4
MUL_UFIX (term_2, value_16bit, u_c); // X^5
MUL_UFIX (u_c, value_16bit, u_c); // X^6
term_3 = u_c; // X^6
term_1 /= 2; // (X^2) / 2!;
term_2 /= 24; // (X^4) / 4!;
term_3 /= 720; // (X^6) / 6!;
c = term_2; // ((X^4) / 4!) - ((X^2) / 2!);
c -= term_1;
c -= term_3;
c += 256; // ((X^4) / 4!) - ((X^2) / 2!) - ((X^6) / 6!) + 1
}
Для вычислений я взял три члена ряда Тейлора. Код никак не оптимизировал для наглядности. Он будет работать от 0 до числа Пи. Дальше уже может быть переполнение переменных (там есть возведение в шестую степень).
В крайних положениях (около нуля и около Пи) функция считает плохо из‑за особенностей этого метода, тут ничего не сделать. Давайте посчитаем косинус 30 градусов:
value_16bit = GET_BIN (0, 134);
taylor_cos ();
value_32bit = c;
fix_point32_number_to_str9_signed ();
Результат:
А должно быть 0,866. Довольно точно.
И давайте проверим косинус 60 градусов:
value_16bit = GET_BIN (1, 12);
taylor_cos ();
value_32bit = c;
fix_point32_number_to_str9_signed ();
И снова всё верно:
Ряд Тейлора действительно работает!
И к вопросу производительности. За один кадр консоль успевает посчитать косинус два раза в режиме 60 кадров. Это значит, что вычисление одного значения занимает около 0,0083 секунды (в реальности даже меньше на пару тысячных). И это без оптимизации. Я думал, что будет значительно хуже.
Планы на будущее
Представленная библиотека будет развиваться, так как я планирую использовать её при разработке трёхмерной игры для NES (на самом деле псевдо-3D). Кроме этого буду пробовать внедрять использование светового пистолета для отстрела врагов.
Хотя есть варианты с реализаций честного 3D, но итоговая картинка будет размером в четверть экрана (из‑за малого объёма видеопамяти), а если выводить её спрайтами, то ещё меньше (но будет работать значительно быстрее). Если получится выжать хотя бы 2–3 фпс при вращении кубика, это будет успех. Тема действительно интересная и весёлая.
Про всё это обязательно напишу подробные статьи (а может, и сделаю видео). Так что подписывайтесь на всех доступных платформах (или хотя бы на Хабре).
PS: Для людей, которые следят за портированием Dangerous Dave, который я описывал в предыдущей статье, есть свежая информация.
По результатам опроса и обсуждений в комментариях я решил разделить порт на два отдельных проекта: один максимально близкий к оригиналу (без элементов выживача, сюжета и т. д.), а второй проект будет полностью самостоятельный survival‑хоррор (его особенности я описывал в прошлой статье). Оба проекта находятся в активной разработке, на странице выкладываются актуальные версии всех моих игр.
А чтобы играть в промежуточные версии и писать свои предложения по разработке, есть смысл подписаться на ТГ‑канал SwampTech (комментарии к постам открыты для всех).
Спасибо за внимание.
Комментарии (16)
daggert
14.10.2024 10:10Подключение ПК к денди? оО UART?
Swamp_Dok Автор
14.10.2024 10:10Да, UART можно попробовать реализовать программно, но довольно сложно правильные тайминги получить без таймеров. Для начала я бы попробовал использовать МК-посредник, а для денди написать какой-нибудь синхронный протолок обмена простой.
А если будет реализована связь с ПК, можно будет хоть интернет раздавать. А это уже сетевые игры и браузеры.
daggert
14.10.2024 10:10Можно использовать 16550 чип в разъеме картриджа, чтобы было антуражно (:
У меня была идея, которую я когда-то писал уважаемому @ClusterM- расширить его картридж добавив туда... модуль вафли. Да, этот модуль будет сильнее чем вся консоль, однако из плюсов мы получим стабильное решение. Далее делается "магазин" игр - по сути сверстанный определенным образом сайт (отображаемый только на консоли и в ее интерфейсе), который при помощи этой ESP'хи скачивает ромы (возможно после покупки) в память к себе и позволяет играть на консоли. Если заморочится - можно замутить даже шифрование для авторских РОМов по какому-либо ключу в картридже и облачные сохранения...
Swamp_Dok Автор
14.10.2024 10:10Да, я тоже о подобном думал, но для удобной отладки игр на реальной консоли.
Реализовать магазин было бы забавно, но ниша слишком специфическая. Вот для сеги или snes такое бы имело смысл, так как они выдают приемлемую графику даже для нашего времени.
ArkadiyMak
14.10.2024 10:10Хотя есть варианты с реализаций честного 3D, но итоговая картинка будет размером в четверть экрана (из‑за малого объёма видеопамяти), а если выводить её спрайтами, то ещё меньше (но будет работать значительно быстрее).
Dendy может выводить графику иначе чем спрайтами? На Dendy был порт Elite, выглядевший вполне трехмерным. Интересно, как это был реализовано.
Swamp_Dok Автор
14.10.2024 10:10Спасибо, надо будет посмотреть как этот порт выглядит. Мне он раньше не попадался.
Проблема с графикой в том, что на денди она вся состоит из тайлов. Отдельно редактировать каждый пикаель нельзя.
Чтоб выводить графику пикселями, нужно генерировать тайлы с расставленными в нужных местах пикселям. И загружать их в видео память. Но одновременно консоль может хранить только 256 уникальных тайлов. А для всего экрана нужно 1024 тайла.
Есть вариант переключать банки видеопамяти во время вывода каждого кадра 4 раза, но все равно с генерировать 1024 тайла это очень долго. Но если ограничить число точек на экране, то можно использовать и весь экран более-менее быстро.
Я делал набросок 2д движка пиксельного. Развернутым циклом получалось вывести на экран максимум 21 тайл за кадр в режиме 60 герц в монохромном режиме, а цветом 15 тайлов. Один тайл занимает 16 байт. Но это замена всех пикселей, отдельные пиксели редактировать быстрее.
Плавное 3д вращение сделать можно, но для очень простых скелетных фигур. Но более сложные модели я буду тожн пробовать выводить.
Ниже пример работы этого движка в монохроме.
jakobz
14.10.2024 10:10На картридже, вместо обычной ROM, для тайлов была RAM. Там где рисуется 3D - раскладывались уникальные тайлы, и CPU мог в них рисовать. Т.е. получался эдакий нелинейный буфер кадра, куда можно рисовать попиксельно.
https://elite.bbcelite.com/deep_dives/drawing_vector_graphics_using_nes_tiles.html
Swamp_Dok Автор
14.10.2024 10:10Я точно так же делал в своих экспериментах с попиксельным рисованием. Генерировал тайлы с нужными включёнными пикселями находу и грузил в видеопамяти.
Уже даже придумал концепцию 3д игры простой) Может получится сделать освещение модели, но проволочная модель точно получится.
И спасибо за ссылку. Посмотрел как авторы Элит реализовали рисование линий. Я почти к тому же самому пришёл, когда выводил линии тайлами, но они организацию памяти немного подругому сделали. Хорошая тема для статьи.
Krey
14.10.2024 10:10А вы смотрели как это реализовано в играх на NES? Например было бы интересно узнать какие мотоды использовались в несовской Elite, отличались ли они от её версий на других 8-битниках.
Swamp_Dok Автор
14.10.2024 10:10Нет, Elitе на денди мне не попадалась. Но для неё скорее всего исходников нет открытых, а дизассемблировать и разбирать весь движок - это огромная работа. Все игры для денди построены на всяких хаках, поэтому там внутри скорее всего много жести.
Slager
14.10.2024 10:10Немного непонятно, а каст множителей к (ufix24_8) можно ли заменить на (ufix16_8)?
Swamp_Dok Автор
14.10.2024 10:10Ufix24_8 - это обычный uint32_t, т.е. беззнаковая переменая на 4 байта. А стандартных типов на 3 байта нет, поэтому просто так не заменить.
Но можно реализовать свой трехбайтный тип и прописать для него всю математику. Это имеет смысл, так как экономит лишние копирования байтов. И 2 байта хватит на целую часть. Так что идея правильная.
rbdr
Какая-то путаница вокруг компонента формулы X * 1256. Там по идее X * 1 / 256 -> X / 256?
При геренации картинок потерялась дробь в формулах?
Swamp_Dok Автор
Спасибо, действительно потерялась дробь.