Making of Lo-Fi Motion

Приветствую всех читателей хайпа!



Я планировал написать making of про демку ещё в феврале, но только сейчас звёзды сошлись. Ну ничего, лучше поздно, чем никогда. Началось всё в декабре 2019-го, когда мне внезапно по телефону позвонил fatalsnipe, и спросил, не хочу ли я сделать хоть какое демо на DiHalt 2020 Lite. Хотя до пати оставалось меньше месяца, я согласился, т.к. первоначальный планом было порыться в старых загашниках, взять пару эффектов и склепать что-нибудь абы было.

15-го декабря фатал прислал музыку. Уже после релиза я узнал, что с этим треком связана некоторая teh dorama, но оставлю это на совести участников драмы.

Моё мнение, что музыка — это 80% демки. Крутые эффекты не смотрятся под плохой трек, а с другой стороны, хороший трек может вынести даже эффекты на бейсике (а ещё под хороший трек эффекты придумываются легче). Так вот, послушав трек, я понял что те экскременты, которые я откопал в старых TRD-шках, совершенно под него не подходят. И в голове сразу возникла идея аттрибутного демо а-ля крутёлки из Hackers Top 2010 invitation:



Как потом скажет дед отечественного спектрумизма: “белорусский пиксель. продолговатый. не такой как этот сибирский мультиколор”. К слову сказать, “белорусский пиксель” он не мультиколорный, в этом его идея, чтоб даже на бейсике можно было делать эффекты в таком стиле.

И понеслось…


Основную структуру кода, папок, модулей, именование файлов, сборочные скрипты и несколько простых процедур (половина из которых в итоге так и не пригодилась), я взял из Pre-ZU. Над развитием мысли можно понаблюдать просмотрев историю коммитов в репозитории, я же опишу конечный результат, с историческими справками.

Demo loop


Demo loop очень простой — из таблицы со сценами он читает банку в которой лежит следуюая сцена, адреса входа, конфигурацию, продолжительность (во фреймах), и прокручивает сцену необходимое количество раз, после переходит к следующей. Параллельно крутится text printer (я его делал в самом конце, поэтому он с большего никак не связан со сценами, но сейчас бы я, наверное, делал бы по другому). Фреймы считаются на прерывании, так что как бы не тормозили эффекты или наоборот, с какой бы турбой их не запускали, тайминг не собьётся.

Почти все (кроме одного) эффекты рендерят в виртуальный буффер, где 1 байт соответствует одному “белорусскому пикселю”, поэтому demo loop сам занимаемся вызовом c2p и щёлканьем банок с экранами. Высота буфера конфигурируемая, в релизе для красоты и скорости используется 32 полузнакоместа по высоте, но можно наконфигурить и в полный экран (48 полузнакомест по высоте).


@core_rows equ 32 ; 32 or 48


Ещё можно включить измерение таймингов по бордеру, но смысла мало, т.к. фреймовых (без турбы) эффектов ровно два — это статическая картинка и “дождик”.


; define _BORDERMETER


Сама табица сцен генерируется с помощью lua (не было охоты разбираться с макросами, а lua я достаточно неплохо знаю). Первый параметр — длительность, последний — параметр к эффекту, в коде называется strength (“сила” эффекта). Если же длительность сцены — 0 (ноль), то менеджер переходит на сцену с адресом, который следует за нулём. Тем самым организован бесконечный loop.


    ...

    sj.parse_line(".loop")

    make_effect(0x0100, "rain", 2)
    make_effect(0x0100, "rain", 1)

    --

    make_effect(0x100, "dina", 1)
    make_effect(0x100, "rtzoomer", 2)
    make_effect(0x100, "dina", 2)
    make_effect(0x100, "rtzoomer", 1)

endlua

dw 0 : dw scenes.loop


Интерфейс эффекта представляет собой 3 точки входа: “enter”, “render” и “leave”.

  • “enter” вызывается менеджером после перехода от предыдущего эффекта, и в этой процедуре можно сделать подготовительную работу, как-то наконфигурировать эффект, чтоб не делать этого каждый фрейм.
  • “render” занимается собственно отрисовкой эффекта.
  • “leave” вызывается менеджером перед переходом к следующему эффекту, чтобы была возможность “убрать за собой”. Используется эта возможность в единственном эффекте, который показывает статическую полноэкранную картинку, и в “leave” обратно настраивает “demo loop” на продолговатые пиксели.

С2P


С большего рендер буффера в экран похож на таковые с чанками, только попроще. Изначально экран заливается таким образом, что вехрняя половина знакоместа пустая (PAPER), а нижняя залита (INK).

hl — адрес буфера
de’ — адрес следующей строки в буфере
d — адрес палитры для верхней части знакоместа (палитра заалайнена по 256)
h’ — адрес палитры для нижней части знакоместа
bc — адрес в экране


ld a,(de)
ld l,a    ; “hl” указывает на PAPER

exx
ld e,(hl) ; “de” указывает на INK
ld a,(de) ; в “a” - INK
inc l
exx

or (hl)   ; смешиваем “a” с PAPER
ld (bc),a ; устанавливаем нужное знакоместо в экране
inc e
inc c


Над оптимизацией особо не заморачивался (на одно знакоместо в экране уходит 59 тактов), всё что сделал — это через DUP / EDUP за unroll-лил цикл по строке. То же можно сказать и про оптимизацию в остальных эффектах — unroll и небольшая самомодификация кода — это все оптимизации которые я делал. Сорямба :)

Про палитру — это не только часть оптимизации C2P. Изначально я хотел делать эффекты в разных палитрах. Даже была идея сделать дему в гигаскрине (когда через кадр меняются экраны с таким диким мерцанием), но поприкидывав палитры в фотошопе я понял, что художник из меня от слова “худо”, и вся дема выполнена в единственной “программистской” палитре.

Эффект “raskolbas”



Идея этого эффекта берёт начало в интре к TargeT#8 за авторством Hirurg-a, но в версии для демки он стал цветным, линии прямыми, а вместо картинок — прямоугольники случайного размера.

Код эффекта тривиален — каждый фрейм фейдим буффер (-1 к “пикселю”), а каждый второй фрейм вызываем много рандома, который решает рисовать вообще или нет, а так же где. Эффект конфигурируется (по битовой маске можно включать / выключать рисование вертикальных линий, горизонтальных линий и прямоугольников).


cfg_strength_1 equ cfg_vline
cfg_strength_2 equ cfg_hline
cfg_strength_3 equ cfg_vline or cfg_box
cfg_strength_4 equ cfg_hline or cfg_box
cfg_strength_5 equ cfg_vline or cfg_hline or cfg_box


Рандом совершенно дурацкий — он далеко не самый быстрый, и у него маленький период (после которого последовательность начинает повторяться). Когда-то в детстве меня ему научил Tom Hial, и с тех пор этот рандом кочует по моим проектам. У меня есть подборка рандомов получше, но руки никак не доходят их применить.


random_u8
    ld hl,.seed : ld a,(hl)
    dup 7 : inc l : add a,(hl) : ld (hl),a : edup
    ld (.seed),a
    ret

    org (($ + 7) / 8) * 8

.seed
    ;   12345678
    db "TGT_TEAM"


Кстати о том, как хорошая музыка спасает слабые эффекты. В самом начале демо может показаться что линии как-то “пофикшены” под музыку, но это только так кажется. Это просто random + хороший трек.

Эффекты “slime” и “fire”



Классический эффект огня и тот же огонь, но немного по другой формуле и перевёрнутый вверх-ногами. Эффект огня это, наверное, мой вообще самый первый демо-эффект который я написал, причём это было не на спектруме, а на PC в турбо паскале во время пребывания в лагере для детей “Наука”. Дальше память обрывается, и то ли в это же время в этом же лагере отдыхал небезызвестный Mad Rain / Fenomen, который меня и научил меня этому эффекту, то ли я просто где-то нашёл исходники на каком-то сетевом диске.

Суть проста. Нижнюю строку экрана заполняем рандомом. Далее проходим по экрану сверху-вниз и каждому пикселю присваиваем значение по такой формуле:


X = (A + B + C + D) / 4

[ ] [X] [ ]
[A] [B] [C]
[ ] [D] [ ]


Для эффекта “slime” формула немного попроще (и заполняется первая строка, а проход идёт снизу-вверх):


[ ] [A] [ ]
[ ] [B] [ ]
[ ] [X] [ ]

X = (A + B) / 2


Оба эффекта позволяют конфигурировать маску на тот цвет, которым идёт заполнение, из-за чего можно играть с “силой огня”.

Эффект “interp”




Тоже классический эффект демосцены. Как следует из его названия, его суть заключается в интерполяции. Генерируем 4 цвета для верхней-левой, верхней-правой, нижней-левой и нижней-правой точек экрана, а затем выполняем между ними интерполяцию.


[A] ---- > ---- [B]
 |               |
 |               |
 v  ...........  v
 |               |
 |               |
[C] ---- > ---- [D]


Опорные цвета генерируются не рандомом (иначе был бы мульти-шыт), а берутся из синусной таблички, но каждый со своим шагом. В эффекте можно сконфигурировать маску, которая накладывается на результирующие точки (если накладывать маску на исходные точки, то было бы слишком куцо).

Эффект “plasma”




Отдалённо этот эффект похож на интерполяцию, но только вместо интерполяции надо “ходить” по синусным табличкам. Синус на синусе синусом погоняет :) Приведу код на неком псевдо C-подобном языке, он гораздо понятней асмовой портянки. Как и все остальные эффекты, этот тоже конфигурируется (pixelMask, ...Addend).


// IN:
//
// uint8_t sintab[256]
// uint8_t innerLeftIndex
// uint8_t innerRightIndex
// uint8_t outerLeftAddend
// uint8_t outerRightAddend
// uint8_t innerLeftAddend
// uint8_t innerRightAddend
// uint8_t pixelMask
// uint8_t* pixelBuffer

for (uint8_t row = 0; row < 32; ++row) {
    innerLeftIndex = sintab[outerLeftIndex]
    innerRightIndex = sintab[outerRightIndex]

    for (uint8_t col = 0; col < 32; ++col) {
        uint8_t pixelIndex = sintab[innerLeft] + sintab[innerRight]
        *(pixelBuffer++) = (sintab[pixelIndex] >> 3) & pixelMask

        innerLeftIndex += innerLeftAddend
        innerRightIndex += innerRightAddend
    }

    outerLeftIndex += outerLeftAddend
    outerRightIndex += outerRightAddend
}


Эффекты “rain” и “burb”




“rain” — дождик сверху-вниз, “burb” — бурбалки (от этого и название) снизу-вверх. Сдвигаем экран на 1 пиксель вниз (вверх) и ставим точку в рандомной позиции первой (последней) строки. Точка ставится не каждый фрейм, а по рандому. В эффекте можно конфигурировать вероятность выставления точки, и маску на цвет.

Эффект “logo”




Картинку для этого эффекта я попросил нарисовать своего старого знакомого (который ещё меньше чем я активничает на сцене). Несмотря на всю простоту эффекта (всего лишь вывести картинку), он потребовал для себя самых больших изменений в движке. Именно ради него были добавлены точки входа “enter” и “leave” в эффект, и если “enter” оказался полезным и для других эффектов, то “leave” используется только тут. Так же это один из двух эффектов, которые не конфигурируются.

Эффект “dina”




Наверняка у этого эффекта есть более известное название, но я его назвал “dina” т.к. в первый раз я его делал для интры к невышедшему трейнеру для игрухи Dina Blaster, который мы когда-то делали с Fox Fluffy’s-ом. В той интре эффект был пикельным, с неоптимизированным кодом и с длюннющим декранчем, но тут полуаттрибуты дали возможность реализовать его в реалтайме.

Суть такова — есть текстура:


A B C D
E F G H
I J K L
M N O P


Двигаем её сначали по горизонтали (конец строки “заворачивается” в начало):


        | rotate by
A B C D | 0
H E F G | 1
K L I J | 2
P M N O | 1


А потом таким же образом, но уже по вертикали:


0 1 2 1 - rotate by
-------
A E I G
H L N J
K M C O
P B F D


Или сначала по вертикали, а потом по горизонтали — это не принципиально. В реальном коде, понятное дело, что ничто никуда не двигается, а вместо этого код хитро шагает по текстуре при отрисовке сверху-вниз и слева-направо. Параметры синусоидных по которым сдвигается текстура (как и сама текстура) — конфигурируемые. Псевдо-код на псевдо-C:


// IN:
//
// uint8_t sintab[256]
// uint8_t texture[256]
// uint8_t hIndex
// uint8_t vIndex
// uint8_t hAddend
// uint8_t vAddend
// uint8_t* pixelBuffer

uint8_t vTexOffset = 0

for (uint8_t row = 0; row < 32; ++row) {
    uint8_t hTexOffset = sintab[hIndex] >> 5
    uint8_t vIndexInner = vIndex

    for (uint8_t col = 0; col < 32; ++col) {
        uint8_t vTexOffset = (sintab[vIndexInner] + vTexOffset) >> 1
        *(pixelBuffer++) = texture[vTexOffset & 0xF0 | hTexOffset]
        vIndexInner += vAddend
        hTexOffset = (hTexOffset + 1) % 0x0F
    }

    hIndex += hAddend
    vTexOffset += 0x10
}


Эффект “rtzoomer”




Классический эффект демосцены — rotozoomer. Принцип работы такой же как в “dina” — с заранее вычисленными значениями смещения шагаем по текстуре, при отрисовке сверху-вниз и слева-направо. В этом эффекте конфигурируется только текстура (ещё можно было бы конфигурировать принцип рассчёта смещений в текстуре в зависимости от текущего фрейма, но я этого не делал). По традиции псевдо-код на превдо-C:


// IN:
//
// uint8_t sintab[256]
// uint8_t texture[256]
// uint8_t ticks
// uint8_t* pixelBuffer

uint16_t xAddendOuter = (int8_t) sintab[ticks]
uint16_t yAddendOuter = (uint16_t) ((int8_t) sintab[(uint8_t) (ticks + 192)]) << 2
uint16_t xAddendInner = (int8_t) (sintab[(uint8_t) (ticks + 64)] >> 1)
uint16_t yAddendInner = xAddendOuter << 4

uint16_t xOffsetOuter = (uint16_t) sintab[(uint8_t) ((ticks << 1) | (ticks >> 7))] << 4
uint16_t yOffsetOuter = sintab[(uint8_t) (ticks + 64)] << 8

for (uint8_t row = 0; row < 32; ++row) {
    uint16_t xOffsetInner = xOffsetOuter
    uint16_t yOffsetInner = yOffsetOuter

    for (uint8_t col = 0; col < 32; ++col) {
        uint8_t texOffset = ((yOffsetInner >> 8) & 0xF0) | ((xOffsetInner >> 8) & 0x0F)
        *(pixelBuffer++) = texture[texOffset]

        xOffsetInner += xAddendInner
        yOffsetInner += yAddendInner
    }

    xOffsetOuter += xAddendOuter
    yOffsetOuter += yAddendOuter
}


Эффект “rbars”




Vertical raster bars или Kefrens bars. Суть очень проста — заводится буффер размером в одну строку экрана, в буфере в нужном месте рисуется текстура и буфер отправляется на экран. После этого всё повторяется до конца экрана, при этом буфер не чистится, за счёт чего и достигается эффект.


. . . . . A B C D . . . . . . .
          ^ ^ ^ ^

. . . . . A B C D . . . . . . .
. . . A B C D C D . . . . . . .
      ^ ^ ^ ^

. . . . . A B C D . . . . . . .
. . . A B C D C D . . . . . . .
. . . A A B C D D . . . . . . .
        ^ ^ ^ ^

. . . . . A B C D . . . . . . .
. . . A B C D C D . . . . . . .
. . . A A B C D D . . . . . . .
. . . A A B A B C D . . . . . .
            ^ ^ ^ ^


Один нюанс — формула, по которой “колбасится” эффект не должна быть слишком плавной, иначе из-за того что позиция текстуры смещается недостаточно сильно, края у фигуры будут преимущественного одного цвета, без характерного “частокола”. Как и многие другие эффекты, этот тоже параметризуется — в нём можно задать параметры синусоиды и мультипликатора (из-за которого снизу текстура смещается сильнее, чем сверху).

Эффект “bigpic”




Единственный эффект в котором ещё меньше кода чем в эффекте со статической картинкой, и второй эффект который не параметризуется. Эффект настолько примитивен, что по сути и разбирать тут нечего :) Так же если при сборке указать что высота экрана не 32, а 48 “белорусских пикселей”, то эффект будет немного сбоить, т.к. текстура не рассчитана на такую высоту экрана.

Text printer


Печаталка текста писалась в самом конце и в большой спешке, поэтому там нет ничего сверх-хитрого или оптимизированного. Из банальной оптимизации — пиекели рендерятся только при смене текста, тогда как аттрибуты — каждый фрейм, чтоб перекрыть эффект.

Текст разбивается на страницы, каждая страница состоит из строк, которые будут отрисованы определённым цветом в определённом месте. Скрипт / байткод / инструкции для печаталки генерируются с помощью lua-функций:


make_skip(0x0100 * 8)

make_page(0x0020, { { " RESTORER/TGT ", 1, 102, 2, 7 } })
make_page(0x0020, { { " RESTORER/TGT ", 1, 102, 2, 7 }, { " AS CODER ", 5, -102, 2, 7 } })
make_page(0x0020, { { " RESTORER/TGT ", 1, 0, 2, 7 }, { " AS CODER ", 5, 0, 2, 7 } })
make_page(0x0020, { { " RESTORER/TGT ", 0, 0, 2, 7 }, { " AS CODER ", 6, 0, 2, 7 } })


Когда показываются гритсы (кстати, наверное правильней их было бы назвать “респекты”, т.к. к сожалению некоторые из этих замечательных спектрумистов уже покинули нас), позиция текста выбирается по рандому, но мне хотелось рандома не в рантайме, а в compile-time, чтоб я заранее мог оценить как это смотрится, и, если что, точечно где-то что-то подправить. И ещё хотелось предсказуемости результата, чтоб от компиляции к компиляции рандом выдавал одну и ту же последовательность.

В lua есть функция math.random() для рандома, но из-за спешки при написании демо я совершенно упустил что есть ещё и math.randomseed(), который позволяет этот самый seed задать. Поэтому, в коде используется самописный порт рандома из какой-то древней libc. После того как нужный мне рандом был добавлен, какие-то моменты можно было бы порефакторить, применив вместо copy / paste реализацию через циклы и использование рандома, но я забил (т.к. спешка + усталость от кранча).


make_page(0x0010, { { " BEHIND THE CURTAIN ", 0, 101, 1, 7 } })
make_page(0x0010, { { " BEHIND THE CURTAIN ", 0, 102, 5, 0 } })
make_page(0x0010, { { " BEHIND THE CURTAIN ", 0, 103, 7, 0 } })
make_page(0x0010, { { " BEHIND THE CURTAIN ", 0, 104, 3, 7 } })
make_page(0x0010, { { " BEHIND THE CURTAIN ", 0, 105, 2, 7 } })
make_page(0x0010, { { " BEHIND THE CURTAIN ", 0, 106, 6, 0 } })
make_page(0x0010, { { " BEHIND THE CURTAIN ", 0, 107, 4, 0 } })
make_page(0x0010, { { " BEHIND THE CURTAIN ", 0, 108, 1, 7 } })


Тулинг


  • Для ассебмлирования — sjasmplus. Знаю, что есть несколько форков, но я использую самый обычный. Не могу сказать что это идеальный ассемблер, но свою работу выполняет.
  • Для рисования шрифта и фикса картинки — старый добрый bge 3.05 под эмулятором. Если к кросс-ассемблеру я привык быстро (подкупает возможность редактировать текст в нормальном редакторе, а не во встроенном в ALASM), то кросс-рисовалки я пока не освоил.
  • Для работы с trd — trdetz.
  • Для рисования текстур — photoshop, как бы это было не банально.
  • Для конвертации .png-шек в текстуры — собственнописанный скрипт на ruby (есть в репе).
  • Для сборки .tap-ки — собственнописанный скрипт на ruby (тоже есть в репе).
  • Для сжатия — hrust1opt (Optimal Hrust 1.3 compressor by Eugene Larchenko with few small hacks by spke). Несмотря на то, что есть пакеры лучше (и по скорости распаковки, и по степени сжатия) я с давних времён испытываю особую привязанность к хрусту.
  • Для модификации бейсик-загрузчика из Pre-ZU — величий и могучий ZX Basic под эмулятором. Особое “удобство” работы в ZX Basic плюс нехватка времени перед релизом — главные причины того, что при загрузке красуется “Cracked by Bill Gilbert (c) 2019” вместо “… 2020”. Про существование кросс-компилятора bas2tap я узнал уже позже.

P.S.


В итоге, как мне кажется, получилось то, что называют solid product — звёзд с неба не хватает, но и не сказать чтоб совсем какашечка. Для меня отдельным challenge было сделать демку за две недели по вечерам, параллельно с работой и ремонтом, и почти без предыдущих наработок.

Я рад, если демка вам понравилась. И сорян тем, кто ожидал большего, или кому показалось что это мультиколор :)

8 комментариев

avatar
Уже после релиза я узнал, что с этим треком связана некоторая teh dorama,…

Нет Слава, никакой дорамой тут и не пахнет. Просто есть хороший музыкант, но этого не менее отличный распиздяй и балабол. Человек написал трек по конкретной просьбе для конкретного проекта и так же без зазрения совести его отдал другому. И полбеды если бы он его выложил в паблик, а ты его взял не зная предыстории создания, так нет он ещё и навязал на его основании создать работу.

А самое смешное, что после выхода твоей работы, он клятвенно обещал, что тут же напишет другой, не менее крутой — «только для вас здесь и сейчас». Как не трудно догадаться — «а трек у нас на подходе» ©
avatar
Спасибо за подробный рассказ! Некоторые эффекты были для меня новыми (например, интерполяцию я не встречал, и про капли я не заметил что они были из целых квадратов, на глаз казалось что сделано пикселями). Ну и в целом, всегда интересно какие технические решения были приняты и почему. Мне не понравилась палитра, поэтому отдельное спасибо за честное объяснение почему именно такая :)

Всё вместе получилось, ОК, я согласен что не восторг, но сделано крепко. Мне было интересно увидеть демо в чём-то очень олдскульное, но в чём-то ещё и с элементами ньюскула (хотя, как я сейчас понял из пояснений, фикса в ней было меньше чем мне тогда показалось). Но любое успешное «надурил» — всегда в плюс авторам и в плюс демо.
avatar
Кстати про палитры. Может знаешь в каких демках лучше посмотреть референсы, или может где-то есть какие-то наработки, которыми не жалко поделиться? Мне приходит в голову только две, синяя (0, 1, 5, 7) и красная (2, 6, 7), и то в красной очень мало переходов. А ведь можно и смешанные какие-нибудь красивые, а можно ж и в гигаскрине… Эх :)
avatar
Воруй палитры у художников на zxart.ee. Просто любой объект у них бери объёмный, ляжка там какая-нибудь, и смотри как идут от самого тёмного цвета к самому яркому цвету.

В гигаскрине цветов больше, палитры рисовать легче.

Технически, то что тебе нужно называется не палитрой, а «color ramp». Мы привыкли плюс-минус менять яркость одного и того же цвета, поэтому у нас на спектруме выходит очень мало вариантов. А художники умеют прямо в голове, по мере тего как они меняют яркость, менять ещё и hue. Если изменения hue более-менее последовательные, выходит не хуже чем если бы мы просто меняли яркость.

Подробнее, конечно, лучше художников расспрашивать.
avatar
Настанет время и я напишу такую же лесу, но к тому времени я тут останусь один.
avatar
хайп ваще торт, спасибо, автор, демо понравилось, а с рассказом стало ещё интереснее смотреть, так держать!
avatar
Понравилось демо. Этакий олдскул с человеческим лицом, как выше написал интроспек. Точнее, он так не написал, но подумал. Ну или не подумал.

И отдельное спасибо за making of — моя любимая часть в любом демо.
  • nyuk
  • +1
avatar
Не прошло и ста лет, как я обнаружил, что исходники демо выложены на GitHub:
github.com/restorer/zxspectrum-lo-fi-motion-2020
Большое спасибо автору! Надеюсь это поможет другим невнимательным читателям вроде меня!
Только зарегистрированные и авторизованные пользователи могут оставлять комментарии.