Небольшой рассказ о том, как впихнуть невпихуемое и отобразить в реальном времени трехмерную графику при помощи контроллера, у которого недостаточно ни скорости, ни памяти для этого.
В далеком 2017 году (судя по дате модификации файла) решил перейти я с контроллеров AVR на более мощные STM32. Естественно, первым контроллером был выбран широко распиаренный F103. Не менее естественно, что использование готовых отладочных плат было отвергнуто в пользу изготовления своей с нуля согласно своим требованиям. Как ни странно, обошлось почти без косяков (разве что UART1 стоило бы вывести на нормальный разъем, а не костылить на проводках).
По сравнению с AVR характеристики камня довольно приличные: 72 МГц тактовой (на практике можно разогнать до 100 МГц, а то и больше, но только на свой страх и риск!), 20 кБ оперативки и 64 кБ флеша. Плюс тонна периферии, при использовании которой главная проблема — не испугаться этого изобилия и осознать, что для запуска не нужно лопатить все десять регистров, достаточно выставить три бита в нужных. По крайней мере до тех пор, пока не захотите странного.
Когда прошла первая эйфория от обладания такой мощью, возникло желание прощупать ее пределы. В качестве эффектного примера я выбрал расчет трехмерной графики со всеми этими матрицами, освещением, полигональными моделями и Z-буфером с выводом на дисплей 320х240 на контроллере ili9341. Две самые очевидные проблемы, которые предстоит решить — скорость и объем. Размер экрана 320х240 при 16 битах на цвет дает 150 кБ на один кадр. А всего оперативки у нас всего 20 кБ… И эти 150 кБ надо передавать на дисплей хотя бы 10 раз в секунду, то есть скорость обмена должна составлять хотя бы 1.5 МБ/с или 12 Мб/с, что уже выглядит существенной нагрузкой на ядро. К счастью, в данном контроллере присутствует модуль ПДП (прямой доступ к памяти, он же Direct Memory Access, DMA), который позволяет не нагружать ядро операциями переливания из пустого в порожнее. То есть можно подготовить буфер, сказать модулю «вот тебе буфер данных, работай!», а в это время готовить данные для следующей передачи. А если учесть способность дисплея принимать данные потоком, вырисовывается следующий алгоритм: выделяется передний буфер, из которого DMA передает данные на дисплей, задний буфер, в который происходит рендеринг, и Z-буфер, используемый для отсечения по глубине. Буферы представляют собой одну строку (или столбец, неважно) дисплея. И вот вместо 150 кБ нам требуется всего 1920 байт (320 пикселей на строку * 3 буфера * 2 байта на точку), что отлично помещается в память. Второй хак основан на том, что расчет матриц преобразования и координат вершин нельзя проводить для каждой строки, иначе изображение будет искажаться самыми причудливыми способами, да и по скорости это невыгодно. Вместо этого «внешние» расчеты, то есть перемножение матриц трансформации и их применение к вершинам пересчитываются на каждом кадре, после чего преобразуются в промежуточное представление, которое оптимизировано для рендеринга в картинку 320х1.
Из хулиганских соображений снаружи библиотека будет напоминать OpenGL. Как и в оригинальной OpenGL отрисовка начинается с формирования матрицы преобразования — очистка glLoadIdentity() делает текущую матрицу единичной, потом набор преобразований glRotateXY(...), glTranslate(…), каждое из которых умножается на текущую матрицу. Поскольку эти расчеты будут проводиться только один раз за кадр, особых требований к скорости нет, можно обойтись простыми float, без извращений с числами с фиксированной точкой. Сама матрица представляет собой массив float[4][4], отображенный на одномерный массив float[16] — вообще-то этот способ обычно применяется для динамических массивов, но и из статических можно извлечь немного выгоды. Еще один стандартный хак: вместо постоянного вычисления синусов и косинусов, которых в матрицах поворота немало, посчитаем их заранее и запишем в табличку. Для этого поделим полный круг на 256 частей, вычислим значение синуса для каждой и свалим его в массив sin_table[]. Ну а получить из синуса косинус сможет любой, кто учился в школе. Стоит отметить, что функции поворота принимают угол не в радианах, а в долях от полного оборота, после приведения к диапазону [0… 255]. Впрочем, реализованы и «честные» функции, выполняющие преобразование из угла в доли под капотом.
Когда матрица готова, можно приступить к отрисовке примитивов. Вообще, в трехмерной графике есть три типа примитивов — точка, линия и треугольник. Но если нас интересуют полигональные модели, внимание следует уделить только треугольнику. Его «отрисовка» происходит в функции glDrawTriangle() или glDrawTriangleV(). Слово «отрисовка» поставлено в кавычки потому что никакой отрисовки на данном этапе не происходит. Мы всего лишь умножаем все точки примитива на матрицу трансформации, после чего выковыриваем из них аналитические формулы ребер y = ky*x + by, которые позволяют найти пересечения всех трех ребер треугольника с текущей выводимой строкой. Одну из них отбросим, поскольку она лежит не на отрезке между вершин, а на его продолжении. То есть для отрисовки кадра нужно всего лишь пройти по всем строкам и для каждой закрасить область между точками пересечения. Но если применить этот алгоритм «в лоб», каждый примитив будет перекрывать те, что были нарисованы раньше. Нам же нужно учитывать Z-координату (глубину), чтобы треугольники пересекались красиво. Вместо простого вывода точки за точкой будем считать ее Z-координату и по сравнению с Z-координатой, хранимой в буфере глубины, либо выводить (обновляя Z-буфер), либо игнорировать. А для расчета Z-координаты каждой точки интересующей нас строки воспользуемся той же формулой прямой линии z = kz*y + bz, вычисленной по тем самым двум точкам пересечения с ребрами. В результате объект «полуфабрикатного» треугольника struct glTriangle состоит из трех X-координат вершин (хранить Y и Z-координаты смысла нет, они будут вычисляться) и k, b коэффициентов прямых, ну и цвет до кучи. Вот здесь, в отличие от расчета матриц преобразования, скорость критична, поэтому уже используем числа с фиксированной точкой. Причем если для слагаемого b достаточно той же точности, что для координат (2 байта), то точность множителя k чем больше, тем лучше, поэтому берем 4 байта. Но не float, поскольку работа с целыми числами все равно быстрее, даже при одинаковом размере.
Итак, вызвав кучу glDrawTriangle() мы подготовили массив полуфабрикатных треугольников. В моей реализации треугольники выводятся по одному явными вызовами функции. На самом деле было бы логично завести массив треугольников с адресами вершин, но здесь я решил не усложнять. Все равно функция отрисовки пишется роботами, а им без разницы, заполнять ли константный массив или писать триста одинаковых вызовов. Пришло время перевести полуфабрикаты треугольников в красивую картинку на экране. Для этого вызывается функция glSwapBuffers(). Как и было описано выше, она проходит по строкам дисплея, ищет для каждой точки пересечения со всеми треугольниками и рисует отрезки в соответствии с фильтрацией по глубине. После рендеринга каждой строки нужно эту строку отправить на дисплей. Для этого запускается DMA, которому указывается адрес строки и ее размер. А пока DMA работает, можно переключиться на другой буфер и рендерить уже следующую строку. Главное не забыть дождаться окончания передачи если вдруг закончили отрисовку раньше. Для визуализации соотношения скоростей я добавил включение красного светодиода после окончания рендеринга и выключение после завершения ожидания DMA. Получается что-то вроде ШИМ, который регулирует яркость в зависимости от времени ожидания. Теоретически вместо «тупого» ожидания можно было бы использовать прерывания DMA, но тогда я не умел ими пользоваться, да и алгоритм существенно бы усложнился. Для демонстрационной программы это излишне.
Результатом описанных выше процедур явилась вращающаяся картинка трех пересекающихся плоскостей разных цветов, причем с довольно приличной скоростью: яркость красного светодиода довольно велика, что говорит о большом запасе по производительности ядра.
Что ж, если ядро простаивает, надо его нагрузить. А нагружать его будем более качественными моделями. Правда, не стоит забывать, что память все-таки сильно ограничена, так что слишком много полигонов контроллер не потянет физически. Простейший расчет показал, что после вычитания памяти на буфер строки и тому подобное, осталось место на 378 треугольников. Как показала практика, под этот размер отлично подходят модели из старой, но интересной игры Gothic. Собственно, оттуда были выдернуты модельки шныга и кровавой мухи (а уже при написании этой статьи и мракориса, красующегося на КДПВ), после чего у контроллера кончилась флеш-память. Но игровые модельки не предназначены для использования микроконтроллером.
Скажем, они содержат анимацию, текстуры и тому подобное, что нам не пригодится, да и не поместится в память. К счастью, blender позволяет не только пересохранить их в *.obj, более поддающийся парсингу, но и уменьшить количество полигонов если нужно. Дальше при помощи простенькой самописной программы obj2arr *.obj файлы разбираются на координаты, из которых впоследствии формируется *.h файл для непосредственного включения в прошивку.
Но пока что модельки выглядят просто как одноцветные фигурные кляксы. На тестовой модели это нас не волновало, поскольку все грани были раскрашены в свои цвета, но не прописывать же цвета каждому полигону модельки. Нет, можно, конечно и муху раскрасить в рандомные цвета, но смотреться это будет довольно вырвиглазно, я проверял. Особенно когда цвета еще и меняются на каждом кадре… Вместо этого применим еще капельку векторной магии и добавим освещение.
Расчет освещения в его примитивном варианте заключается в расчете скалярного произведения нормали и направления на источник света с последующим умножением на «родной» цвет грани.
Моделек у нас теперь три — две из игры и одна тестовая, с которой начинали. Для их переключения воспользуемся одной из двух кнопок, распаянных на плате. Заодно можно добавить контроль загруженности процессора. Один контроль у нас уже есть — красный светодиод, связанный с временем ожидания DMA. А вторым, зеленым, светодиодом, будем мигать при каждом обновлении кадра — так мы сможем оценить частоту кадров. Для невооруженного глаза она составила что-то около 15 fps.
В общем-то, я полученным результатом удовлетворен: приятно реализовать что-то, что принципиально невозможно решить в лоб. Конечно, тут еще есть куда оптимизировать и улучшать, но смысла в этом немного. Объективно контроллер для трехмерной графики слаб, причем речь даже не про скорость, а скорее про оперативную память. Впрочем, как и любой образец демосцены, этот проект ценен не результатом, а процессом.
Если вдруг кому-то будет интересно, исходный код доступен тут.
mmMike
Это громко сказано… про недостаточность памяти и скорости. Если сравнить со "спектрум" Z80.
Игрушки были приличные.
COKPOWEHEU Автор
Не воспринимайте излишне серьезно. Будь у контроллера объективно недостаточно ресурсов, это и сделать было бы невозможно.
А на счет Спектрума — мне казалось, у него все-таки была какая-то аппаратная видеокарта. Да и вообще специфика разная.
mmMike
У Спектрума аппаратная часть на рассыпухе "отображала" часть ОЗУ в видеосигналы (если упрощенно).
С учетом того, что ili9341 это то же память + аппаратная часть, а запись через SPI + DMA в ОЗУ ili9341, в общем то, сравнима с прямой записью в ОЗУ на типичных 8Мгц Z80…
Те же яйца, только вид сбоку.
И я не принял серьезно :) Вот если бы на AVR 8 битовом… что гораздо ближе к Спектруму по быстродействию и памяти.
DolphinSoft
>Вот если бы на AVR 8 битовом… что гораздо ближе к Спектруму по быстродействию и памяти.
Так-то AVR8, без зазрения совести:
da-nie
Там, помнится, было два AVR. Один — эмуляция Z80, а второй эмуляция периферии.
DolphinSoft
Видео на втором, все остальное на первом.
И для звука еще один мелкий вроде.
da-nie
Нет, у спектрума нет вообще ничего для аппаратного ускорения. Но весь экран 6144 байта (чёрно-белый)+768 байт атрибуты цветов.
LibrarianOok
Не было ли желания замахнуться на реализацию реймаршера на таком аппарате?
COKPOWEHEU Автор
Нет. Эта поделка — разовая, она не для какой-то цели или отработки алгоритма для дальнейшего применения. Просто было интересно что можно выжать, и то без фанатизма. Изучать другие низкоуровневые алгоритмы вывода изображений на примере stm-ки смысла нет: мощность невелика, а все оптимизации будут жестко завязаны на ее же специфику.
LibrarianOok
Ну, многие трюки показались небезынтересными в плане ускорения вычислений. Я иногда пробую всякие волюнтаристские штуки, вроде приравнивания числа пи четырём и возведения в половинную степень вместо извлечения корня.
Zolg
Это разные записи одной и той же операции
LibrarianOok
Так-то да. Только скорость выполнения разная.
Zolg
А есть бенчмарки?
Я сильно сомневаюсь, что более универсальная функция pow() оптимизирована лучше специализированной sqrt(). Тем более, что последняя на многих платформах реализована аппаратно.
Если уж говорить про "грязную скорость" sqrt() (вернее 1/sqrt(), что более востребовано), то вспоминать нужно про 0x5f3759df
LibrarianOok
Эта квейковская загогулина работает не только лишь везде, а вообще мало где. Она расчитана под чистый, незамутнённый 32-битный float, и ни на что больше. Бенчмарки надо разумеется писать. Вполне может оказаться, что для ваших случаев функция отработает быстрее арифметического оператора возведения в степень "^", который я использовал вместо math.pow(). В моих поделиях оператор отработал даже быстрее переопределённой функции local pow = math.pow
Заодно вопхну сюда ответ на вопрос про число PI, поскольку набежавшие в срачик про мед. маски «специалисты по всем вопросам» подслили карму и писать комменты получается теперь не чаще раза в час.
Zolg
Именно поэтому это
грязный хакизящная чертова магия.В принципе подобную магию можно сотворить и под float64, но особого смысла это не имеет, т.к. метод дает приближенное значение обратного корня и точности float32 более чем достаточно.
раз вы утверждаете, что a быстрее b, то либо вы бенчмарки писали/проводили, либо ваше утверждение голословно
я не знаю, про какой именно язык программирования вы говорите, но в любом случае бинарный оператор это просто сахар для вызова функции/метода.
LibrarianOok
Я там опечатался в ответе. Не math.pow() имелось в виду, а math.sqrt(), по сравнению с оператором "^". Ну, немного подредактировав бенчмарки, можно сравнить например «math.pow(a)» c «a*a» и с «a^2». Касательно квадратного корня получилось вот так:
Zolg
Это прекрасно во всем: и использование lua для теста числодробильни и надежда на то, что 35.73^0.5 вычисляется более одного раза при интерпретации (и хотябы один раз в скомпилированном коде).
замените код вашего бенчмарка хотябы на
и поделитесь результатом )
LibrarianOok
Забавно. В luajit все три варианта показали примерно одинаковую скорость. А вот в lua, как таковом, внезапно быстрее оказалась локально объявленная функция.
Zolg
Вы не догадываетесь, почему?
Скорее всего ситуацию поменяет банальный print(b) в самом конце программы (даже после print(a)). Если же компилятор уапще умный, то добавьте ввод b, как параметра командной строки
LibrarianOok
Кстати, вспомнил штуку, которую так и не попробовал: twitter.com/Soukhinov/status/823506003018862594
Akon32
Хм, надо попробовать аппроксимацию простой нейросетью с ReLU.
Tsvetik
А что будет с картинкой, если пи принять равным 4?
kanvas
Подскажите, возможно есть русскоязычные ссылки как на STM32 реализовать DMA с участием внешней микросхемы статической или динамической ОЗУ? Задача стоит связать STM32+ SRAM 32Kx8 + АЦП 20мегавыборок/сек с параллельной шиной данных + вывод результатов из SRAM 32Кх8 в режиме DMA на ЖК экран (128х160 или 240х320 и подобный из диапазона 150-200/шт). Как реализуемо впринципе такое?
mmMike
На русском… сомневаюсь.
Смотрите в сторону FSMC функционала. Как раз для работы с SRAM и для вывода на экран в через параллельную шину.
Я такое делал.
https://github.com/mmMikeKn/CNC/tree/master/src/liblcd
COKPOWEHEU Автор
А надо ли тут DMA? Если не ошибаюсь, у некоторых контроллеров есть аппаратный интерфейс специально для микросхема памяти, FSMC.
То, что вы описали про АЦП, память и дисплей похоже на цифровой осциллограф, но тут хорошо бы начать с приблизительной оценки — а потянет ли контроллер столько? Что он с этими данными делать будет? Если просто отобразить на дисплее, так там 32 кБ не нужно, хватит тех же 320 байт. Ну пусть 640 если по 2 байта на отсчет. Если передавать в компьютер, то по какому интерфейсу? USB тоже не потянет.
Да даже если АЦП будет выдавать по 20 мегасемплов в секунду, сколько тактов у контроллера уйдет чтобы их забрать и хотя бы сложить в память, не говоря уж об обработке?
Akon32
Эпично.
Я правильно понимаю, что у вас сцена рисуется построчно, по 240 раз на кадр?
А вы не хотите заморочиться и далее, релиазовав поддержку чтения формата Binary GLTF? Этот формат более дружелюбен к современным графическим api, чем obj.
COKPOWEHEU Автор
Не совсем. То есть сцена действительно рисуется построчно и строк 240, но на каждую строку рисуется только та строка примитива, которая в нее попадает. В отличие от «нормальной» графики, где примитив рисуется сразу целиком, потом другой примитив, тоже целиком. Тут в чем-то даже похоже на трассировку лучей. Хотя скорее даже «трассировка веера» если так можно сказать.
На счет glTF, то до вашего вопроса даже не знал что это такое :) Как бы то ни было, развивать именно трехмерку на stm32 я не планирую. Но не исключено, что воспользуюсь в более «взрослой» графике. Спасибо что упомянули про такой формат.
lain8dono
Он дружелюбен (прибит гвоздями) к OpenGL, а не к современным графическим API. JSON-часть этого формата не выглядит хорошим решением для микроконтроллеров. Если выкинуть JSON, то остаётся просто блоб, который загружать даже проще, чем Wavefront OBJ. Достаточно memcpy или аналога, идентичного натуральному.
OpenGL к современным графическим API я не отношу в силу того, что архитектура OpenGL идёт в разрез с тем, как устроенны современные GPU.
andreykorol
Спасибо. Если добавить акселерометр-гироскоп-магнитомер, то возможно было бы получить прикольное управление моделью — как если ее через некое окошко рассматриваешь с разных сторон.
COKPOWEHEU Автор
Ну в чем-то да :)
Правда, чтобы рассмотреть модель снизу придется встать в очень интересную позу
scg
Достаточно лечь на спину. Для меня — это самая естественная поза :)
yarston
Раз уж есть расчёт освещения, можно отбрасывать все полигоны, у которых вектор нормали направлен от экрана — их всё равно не видно. Примерно вполовину количество полигонов для отрисовки уменьшится.
COKPOWEHEU Автор
Файл src/gl.c, строка 282 именно это и делает. Но это вообще стандартный метод удаления невидимых граней при обходе по часовой стрелке или против, тут вообще нет специфики применения контроллера.
axe_chita
Спасибо за статью, прочиталось запоем до последней точки. Ну и для себя надо взять на заметку такую искусную работу с младшей STM32, как пример «Вижу цель, не вижу препятствия!» ;)
EddyEm
Залез в код, ожидал сначала увидеть ужасный калокуб… Но увидел отличные исходники!
Спасибо!!! Хоть немного людей в этом мире еще не превратились в хомячков…
haqreu
Люблю воинственные заявления, спасибо. Только в вашем же журнале вы регулярно жалуетесь на то, что бьётесь-бьётесь, а в итоге железо было неправильно инициализировано…
DolphinSoft
Спасибо за статью!
Хороший и показательный пример использования ресурсов.
От себя добавлю:
Разгоняется до 144 МГц (из периферии становится недоступным только USB), в работе на такой частоте — чуть теплый.
А флеша у этого чипа 128кб. :) (Для демки — самое то).
COKPOWEHEU Автор
144 МГц и 128 кБ это все же как повезет. То есть производитель не только из жадности промаркировал камень меньшим номиналом, что-то на предельных режимах работает недостаточно устойчиво. А значит, в серьезной разработке полагаться на эти возможности нельзя. Для себя же, когда проверил конкретный камень — почему бы и нет, на свой страх и риск.
DolphinSoft
Частота официальна, не заявлена ввиду отсутствия делителя для USB.
И само-собой, это не «в продакшн».
Хотя все протестированные экземпляры пилюль, показали полную работоспособность флеша выше 64кб.
Но я не настаиваю, на нет и суда нет ;)
Просто для такой задачи, это «самое оно».
dmitry_shashkov
Забавно, я пишу сейчас как раз собственный 3д движок под stm32f103 на том же железе. Чтобы прыгнуть выше 15 фпс надо отказаться от z-буффера в пользу сортировки полигонов. Ну и да, я извратился с числами с фиксированной точкой, чтобы избежать флоатов. Модельки пока не грузил. Пока как-то так, на кубах, есть куда оптимизировать. Пилю ещё серию видео на эту тему для ютубчика. ФПС где-то под 30 на глаз, бодро работает.
haqreu
Ну, раз уж пошла такая пьянка, то вот мой рейтрейсинг:
haqreu
COKPOWEHEU Автор
Если вы обратили внимание, в моей демке при выводе пересекающихся квадратов довольно ярко горит красный светодиод, то есть узкое место не в математике, а в пересылке по SPI. Вот на модельках из 200 треугольников (если вычесть отброшенные по отрицательной нормали) там да, проблема в обсчете. Но и то только из-за того что я не заморачивался с оптимизацией. Точки пересечения со строкой считаются в лоб, через умножения, глубина тоже. По-хорошему, стоило бы применить алгоритм Брезенхема и проводить расчет итеративно, за одно сложение и одно сравнение.
Z-координату вообще можно вытянуть из уравнения плоскости:
Z = (D — x*nx — y*ny)/nz,
D = px*nx + py*ny + pz*nz,
где nx,ny,nz — нормаль
pz,py,pz — опорная точка (скажем, любая вершина), но она пересчитывается в одну скалярную константу D
x,y — координаты на плоскости, z — глубина
— Но в целом необходимости в этом немного: в моем варианте память контроллера кончилась ненамного позже запаса быстродействия, выигрыш будет хорошо если в 2 раза на сложных модельках
DolphinSoft
SPI у него тоже разгоняется до 195/2 MHz, и работает без нареканий. (Неофициально, само-собой)
loltrol
Ловите фаната gothic — использованы модельки шныга и кровавой мухи :)
COKPOWEHEU Автор
Там еще на превьюшке третья моделька
А вообще, ловите второго фаната Готики — он узнал эти модельки :)