C:\Users\Heigrast\Downloads\_582dae6e-223c-4819-b7fb-1f5d58855f41.jpg

Консоль 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):

INT +FRAC*(1/256)

Из формулы очевидно, что точность дробной части составляет 0,00 390 625, то есть все значения дробной части кратны этому числу. Это значит, что число в промежутке между (0 — 0,0039) мы никак не можем получить. На практике такой точности часто достаточно, но погрешность всегда нужно учитывать и держать в уме во время разработки программ.

Теперь рассмотрим, как такое число будет выглядеть в памяти. Пусть целая часть равна 145, а дробная — 231. Для процессора это число будет выглядеть так: 1001 0001.1110 0111. Посчитаем его десятичное значение:

145 + 231*(1/256) =145,90234375

Это значит, что число 1001 0001.1110 0111 в формате UFIX8_8 равняется числу 145,90 234 375.

Теперь рассмотрим формат UFIX16_16 (два байта на целую часть и два байта на дробную). Целая часть может хранить значение от 0 до 65 536 (2^16), а дробная единица будет равна числу:

1/165536 = 0,0000152587890625

Всё просто.

Стоит отметить, что для чисел с фиксированной точкой существует и десятичный формат хранения дробной части. Он ещё проще в понимании.

В десятичном формате для целой и дробной части выделяются фиксированное количество десятичных разрядов. Например, если мы хотим хранить 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 (комментарии к постам открыты для всех).

Спасибо за внимание.

Комментарии (8)


  1. rbdr
    14.10.2024 10:10

    Какая-то путаница вокруг компонента формулы X * 1256. Там по идее X * 1 / 256 -> X / 256?
    При геренации картинок потерялась дробь в формулах?


    1. Swamp_Dok Автор
      14.10.2024 10:10

      Спасибо, действительно потерялась дробь.


  1. Zara6502
    14.10.2024 10:10

    INT +FRAC*1256

    а тут не ошибка?


    1. Swamp_Dok Автор
      14.10.2024 10:10

      Да, спасибо.


  1. daggert
    14.10.2024 10:10

    Подключение ПК к денди? оО UART?


    1. Swamp_Dok Автор
      14.10.2024 10:10

      Да, UART можно попробовать реализовать программно, но довольно сложно правильные тайминги получить без таймеров. Для начала я бы попробовал использовать МК-посредник, а для денди написать какой-нибудь синхронный протолок обмена простой.

      А если будет реализована связь с ПК, можно будет хоть интернет раздавать. А это уже сетевые игры и браузеры.


      1. daggert
        14.10.2024 10:10

        Можно использовать 16550 чип в разъеме картриджа, чтобы было антуражно (:

        У меня была идея, которую я когда-то писал уважаемому @ClusterM- расширить его картридж добавив туда... модуль вафли. Да, этот модуль будет сильнее чем вся консоль, однако из плюсов мы получим стабильное решение. Далее делается "магазин" игр - по сути сверстанный определенным образом сайт (отображаемый только на консоли и в ее интерфейсе), который при помощи этой ESP'хи скачивает ромы (возможно после покупки) в память к себе и позволяет играть на консоли. Если заморочится - можно замутить даже шифрование для авторских РОМов по какому-либо ключу в картридже и облачные сохранения...


  1. ArkadiyMak
    14.10.2024 10:10

    Хотя есть варианты с реализаций честного 3D, но итоговая картинка будет размером в четверть экрана (из‑за малого объёма видеопамяти), а если выводить её спрайтами, то ещё меньше (но будет работать значительно быстрее).

    Dendy может выводить графику иначе чем спрайтами? На Dendy был порт Elite, выглядевший вполне трехмерным. Интересно, как это был реализовано.