Игровая легенда из 90-х: Как работала 3dfx Voodoo «под капотом»? Пишем 3D-приложение с нуля на Glide

в 8:08, , рубрики: Без рубрики
image

Полагаю, многие мои читатели так или иначе знакомы с такими видеокартами, как 3dfx Voodoo. Эти легендарные графические ускорители из серединыконца 90-х годов был чуть ли не в каждой второй сборке для игр, а о их производительности слагали легенды. До сих пор есть относительно небольшое сообщество фанатов ретро-игр, которые ценят, любят и собирают с цветмета те немногие видеокарты от 3dfx, что остались в СНГ. Однако обзоров на 3dfx Voodoo много, тестов игр — тоже, а вот материала «простыми словами» о его внутренней архитектуре и более того, практической части с написанием 3D-игры практически нет! Недавно я прикупил себе Voodoo 3, и начал зубрить Programmer's Manual с желанием запилить что-нибудь эдакое… Статью я долго и упорно готовил дабы успеть к новому году и сегодня у нас с вами: краткая история компании 3dfx, подробный разбор архитектуры видеочипов 3dfx «под капотом», что должен был уметь программист 3D-графики в 90х и написание 3D-приложения на Glide полностью с нуля. Интересно? Тогда жду вас в статье!

Предисловие

  1. PSP;
  2. PS1 (как раз читатель недавно задарил фаточку и слимку);
  3. ATI Rage;
  4. Вероятно, GeForce 2.

image

Само собой, я не мог пройти мимо 3dfx Voodoo. И дело не только в ценности и легендарности данных видеочипов, но и во внутренней архитектуре, которая очень сильно отличается от современных GPU. Про 3dfx я знаю уже более 10 лет, ещё с самой юности, а знакомство произошло в одном из выпусков «16-бит тому назад», однако прикупить себе один экземпляр решился только сейчас.

image

Чем же был обусловлен культовый статус 3dfx Voodoo с точки зрения игрока? В первую очередь, 3dfx Voodoo вышел достаточно рано и стал одним из первых (наряду с Rendition Verite) потребительских GPU с оптимальной производительностью и ценой для 3D-игр тех лет. Игры с полноценной 3D-графикой существовали и ранее: вспомнить хотя бы PS1, N64, или японские игровые автоматы из 90-х, однако в играх на ПК в те годы практически всегда был софтварный рендерер, а 3dfx Voodoo позволял достичь графики на уровне, а то и лучше домашних консолей тех лет. За год до выхода Voodoo 1, на рынке появился NVidia NV1 — мультимедийный чип, одной из задач которого было ускорение 3D-графики. Однако, он оперировал не треугольной геометрией, как это принято сейчас, а квадами — т.е прямоугольниками и не был совместим с основными графическими API тех лет, посему и популярности не получил.

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

  • Один FBI: Чип, отвечающий за обмен данными с хост-ПК через PCI/AGP и растеризацию треугольников. Это главный чип в видеокарте, который может работать в паре с другим FBI, образовывая систему из двух видеокарт — SLI.
  • До трёх TMU: Один или несколько чипов, отвечающий за сэмплинг — нанесение текстуры на треугольник. В процессе отрисовки геометрии, FBI передаёт на каждый из указанных программистом TMU данные о текстуре, которую требуется наложить и параметры наложения (фильтрация, мипы и.т.п).

Именно в те годы появилась гонка за объемами видеопамяти на борту и разделение их по классу. Видеокарта с общим объёмом памяти в 16Мб считалась гораздо круче видеокарты с 8Мб: и ведь действительно, больший объём VRAM позволял загрузить текстуры с более высоким разрешением, при этом на частоту видеоядра тогда смотрели гораздо реже.

image
image

Игры для 3dfx Voodoo не заставили себя ждать. Благодаря относительной простоте Glide и понятному мануалу, а также открытости SDK, начали появляться игры с поддержкой данного GAPI: Tomb Raider, MechWarrior, Quake — какие-то игры работали через прослойку (как вышеупомянутая квака, которая реализовывала прослойку Glide -> минимальное подмножество OpenGL, которого хватало для работы игры), какие-то нативно. Выход игр с поддержкой 3dfx Glide обеспечил успех новой видеокарте, поскольку игры зачастую не просто работали шустрее, но и выглядели гораздо симпатичнее.

image

После выхода первой 3dfx Voodoo, компания сосредоточилась на улучшении Glide и доработке архитектуры видео-ускорителей. В 1998 году, спустя два года после выхода первой Voodoo, компания выпустила Voodoo 2 с объемами памяти 8Мб (по 2Мб на TMU) или 12Мб (по 4Мб на TMU), которая архитектурно была похожа на первый видеочип, однако несла в себе несколько серьезных изменений:

  • 64-битная шина для обмена данными с TMU. Помимо этого, 3dfx отмечала то, что благодаря широкой шине, ей удалось реализовать сэмплинг сразу двух текстур за такт.
  • Поддержка Clip-Space координат. Прошлый видеочип рисовал треугольники в абсолютных координатах экрана, а не нормализованных, что в некоторой степени негативно влияло на производительность. При этом для обратной совместимости, поддержка абсолютных координат была оставлена.
  • Поддержка SLI для объединения двух видеокарт для обработки одного кадра. При этом каждая видеокарта работала над своим набором сканлайнов — строк на экране. То есть, первый видеочип мог обрабатывать нечетные строки, а второй четные.

Но тем не менее, 3dfx Voodoo оставались именно видео-ускорителями и полностью заменить видеокарту не могли, поскольку у них не было блока для работы с 2D-графикой (этот блок, в свою очередь, помимо софтварной поддержки VESA-режимов должен включать в себя аппаратное ускорение BitBLT, рисования некоторых примитивов и.т.п). По этой же причине, игры с использованием Glide нельзя было свернуть с помощью Alt-Tab (на самом деле можно, но хаком) и они не могут быть запущены в оконном режиме. По сути, 3dfx Voodoo подключался к полноценной 2D-видеокарте (которой мог быть и ATI Rage, и S3 ViRGE, и ISA-видеокарта с VGA) и полностью переключал сигнал на выход из своего RAMDAC при запуске игры. В общем, игры на Glide с точки зрения системы были простыми консольными приложениями — даже без собственных окон!

image

Немного позже, 3dfx добавила модуль 2D ускорения, сделав полноценную видеокарту, которая могла работать в системе в одиночку. В 1999 году, через год после релиза 3dfx Voodoo 2, вышла Voodoo 3, которая стала системой на кристалле и объединила FBI и два TMU в один кристалл. Видеокарта стала гораздо меньше и обзавелась интерфейсом AGP (который очень близок к PCI сам по себе).

image

Конкуренты у 3dfx Voodoo были серьезные, пожалуй, самым серьезным был ATI Rage: это была небольшая система на кристалле, которая помимо ускорения 3D-графики обладала декодером видео DVD-качества (в те времена, посмотреть видео в 480p и нормальном качестве с софтварным декодером возможно было далеко не на всех процессорах. Насколько мне известно, 486 и Pentium 1 сразу в пролете), умела выводить изображение на телевизор (сам себе домашняя игровая консоль!) и поддерживала не только D3D и OpenGL (причем насколько мне известно, OpenGL поддерживался плохо и такая тенденция сохранялась до покупки ATI компанией AMD), но и собственное проприетарного GAPI ATI CIF (C Interface), которое обладало весьма широкими возможностями и было… ну очень низкоуровневым, приходилось вручную создавать контекст DirectDraw, аттачить его к 3D-контексту, вручную делать backface-отсечение и т. п.

image

Нельзя не вспомнить и за Riva TNT: очень шустрая видеокарта для своих лет, которая также обладала ТВ-выходом и хорошей поддержкой OpenGL. Кроме того, Riva TNT гораздо лучше себя проявляла при 24-х битном цвете: 3dfx Voodoo отрисовывала всё в 16-битном формате. Вон, люди на Riva TNT как-то даже в Морровинд играли!

image
image

В пост-советском пространстве, 3dfx Voodoo была достаточно дорогой и для некоторых оставалась мечтой. Ситуация изменилась ближе к 1999-2001 годам, когда 3dfx с Voodoo 4 и Voodoo 5 была на грани банкротства из-за отсутствия T&L и программируемых шейдеров, а NV и AMD взяли верх, то и Voodoo начали стоить адекватных денег на вторичке: тут уже и GeForce 3 с шейдерами подоспевал, но более бедные геймеры все еще могли поиграть в Quake 3 и другие игры с приемлемым FPS!

Наш сегодняшний герой будет на 3 года меня старше — мой выбор пал на 3dfx Voodoo 3 на 8Мб в AGP-версии, которая среди любителей видеокарт 3dfx считается «не трушной» и посему, самой дешевой. Основной причиной выбора Voodoo 3 была достаточно низкая цена на конкретно это поколение видео-ускорителей — я заплатил 3.400 рублей. 1 и 2 Voodoo стоят в среднем от 4-5 тысяч рублей, а цены Voodoo 4 улетают в космос. Так что скромненько, конечно, но тоже нормально :)

image

Придя домой и распаковав посылку, я сразу же установил Glide SDK и принялся изучать Programmers Manual, дабы запилить что-нибудь интересное…

Архитектура «под капотом»

Но сначала давайте поговорим об архитектуре 3dfx Voodoo «под капотом», на более низком уровне, дабы было общее понимание, что и как работает. Без этого, понять принцип работы GAPI и видеокарты в целом может быть сложно. Предлагаю рассмотреть FBI, TMU и RAMDAC по отдельности:

image

FBI — FrameBuffer Interface. Как уже было сказано выше, этот модуль является основой видеоускорителя: в его задачи входит коммуникации через PCI/AGP, растеризация примитивов, клиппинг, расчет вершинного освещения, подготовка текстур к заливке на TMU, перспективная коррекция, альфа-блендинг и работа с Z-буфером, а также двойная/тройная буферизация и управление самим фреймбуфером.

image

В Voodoo 3, под радиатором находится система на кристалле, где FBI и TMU находятся в одном чипе.

FBI делились на несколько поколений — SST-1 (3dfx Voodoo 1), SST-96 (3dfx Voodoo 2) и.т. п. Судя по всему, у одного FBI могло быть несколько ревизий, но в чем их отличие — неизвестно.

image

Для нужд FBI выделено строго 2Мб видеопамяти, которые используются для 3 буферов, один из которых можно использовать для произвольных целей. Первые два буфера называются Front и Back буферами и отображают то, что сейчас находится на экране, а третий является aux-буфером с 16-битным форматом цвета, который можно использовать для альфа-блендинга, либо как Z-буфер. Видеокарта поддерживает несколько форматов фреймбуфера, однако «под капотом» все расчёты ведутся в 16-битном RGB565 — что когда-то и стало «визитной карточкой» видеокарт 3dfx. Программист может получить прямой доступ к записи в буфер экрана с помощью lfb-функций (аналог в OpenGL — glCopyPixels/glReadPixels).

#define GR_COLORFORMAT_ARGB     0x0
#define GR_COLORFORMAT_ABGR     0x1

#define GR_COLORFORMAT_RGBA     0x2
#define GR_COLORFORMAT_BGRA     0x3

Для FBI выделен отдельный чип памяти на 2Мб или 4Мб. При 2Мб, максимальное разрешение с Aux-буфером составляло 640x480, без Aux-буфера — 800x600, при 4Мб можно было создать буфер с разрешением 800x600, при этом с Aux-буфером.

Ключевой особенностью растеризатора FBI в SST-1 была в том, что вся отрисовка геометрии происходила в абсолютных оконных координатах. В 3D-графике принято все расчеты проводить в т. н. Clip-Space координатах, которые представляют из себя нормализованные экранные координаты (NDC) в пределах -1..1. То есть, точка 0 — центр экрана, -1 — левая сторона экрана, 1 — правая. В SST-1 же все вершины рисовались сразу же в экранных координатах, то есть так:


Vertex v, v2, v3;

v.Position = Vector3(0, 0, 0);
v2.Position = Vector3(15, 0, 0);
v3.Position = Vector3(15, 15, 0);

grDrawTriangle(&v, &v2, &v3);

В 3dfx Voodoo 2 ввели расчеты в Clip-Space, пояснив что растеризация в абсолютных оконных координатах слишком медленная для будущих поколений видеокарт. По итогу, если программисты хотели, чтобы их игра работала на 3dfx Voodoo 1, им нужно было выбирать «старый» режим работы и преобразовывать Clip-Space координаты в оконные вручную.

image

Как уже было сказано выше, вся видеопамять выделялась отдельным TMU и FBI. Но где-же тогда хранилась геометрия? Опытный читатель помнит про такие функции, как glBegin/glEnd. В те годы, видеопамять было принято считать именно текстурной, а геометрию пересылал процессор каждый кадр вручную — причём сразу же трансформированную, подготовленную для вывода на экран. Для каждой вершины в примитиве был свой набор регистров, задающий координаты, цвет и UV для текстур, что и позволяло сделать гибкий формат вершин, чем активно пользуется Glide.

image

Если говорить совсем уж просто, то 3dfx Voodoo был растеризатором треугольников, который ко всему прочему умел Backface-отсечение. Например, не было Near-clipping'а — процесса перестроения примитивов, вершины которых частично уходят за камеру. Если этого не делать — то когда примитив уйдет за камеру, мы все равно продолжим видеть его на экране, а если это дело аппроксимировать, отсекая целые примитивы — некоторая геометрия будет пропадать, если расположена слишком близко к камере. Многие концепции отрисовки 3D-графики программист должен был знать заранее, никаких упрощений, как с современными GAPI, не было.

TMU — Texture Mapping Unit. Как уже было сказано ранее, этот чип отдельно отвечал за сэмплинг текстур, а также управление своими банками видеопамяти и отправкой конечного результата обратно FBI.

image

EDO-чипы EliteMT

Ключевой особенностью TMU была поддержка комбайнеров — своеобразная альтернатива пиксельных шейдеров в те годы. Программист мог задать функцию и параметры работы каждого TMU, дабы он мог выполнять операции умножения текстуры на цвет, или, например, использовать текстуру как маску, манипулировать её альфаканалом и сэмплить сразу две текстуры за один проход! В те годы сама возможность отрисовать геометрию с несколькими текстурами за один проход (т.е за один вызов DrawTriangle) была прорывом и позволяла, например, вместо двух вызовов отрисовки стен в квейке (один проход для текстурированной стены, второй, отрисованный с блендингом, для лайтмапы) рисовать стены за один проход:

image

Максимальный размер текстуры — 256x256 пикселей, также была поддержка текстур с нестандартным соотношением сторон и мипмаппинга (техника, которая позволяет убрать рябь на дальних текстурированных объектах с помощью снижения разрешения текстуры в зависимости от дистанции до наблюдателя). Поддержки Non Power Of Two (не кратные степени двойки) текстур не было вообще. Поддерживалась билинейная и Point-фильтрация. По какой-то причине, 3dfx не могли сделать поддержку текстур большего разрешения чем 256х256, в то время как первая Riva TNT уже умела в текстуры 1024х1024.

image

Память у каждого TMU была отдельной, а Glide как GAPI не предоставлял никакого аллокатора для хранения текстур, программист должен был распоряжаться видеопамятью полностью сам. При этом каждый TMU может сэмплить текстуры только из своей памяти.
Текстуры загружались сразу в виде набора мипмап, однако сам сет мипов мог быть не полным и мог быть расположен в нескольких регионах памяти «вразброс». Таким образом, частично решалась проблема фрагментации памяти. Тем не менее, в те годы динамическая загрузка ресурсов на уровне — весьма нечастое явление, поэтому все текстуры можно было линейно загрузить в память и не заморачиваться с менеджментом.

image

Сами по себе ЭЛТ-мониторы формально не имели такого понятия, как разрешение, однако существовало несколько общепринятых «пиксельклоков» — частот стробов HSYNC и VSYNC, которые и задавали виртуальное разрешение дисплея. Управляя настройками RAMDAC, FBI мог настраивать разрешение картинки, частоту синхронизации и иные параметры — например, настройки гаммы. Именно от используемого RAMDAC зависело качество изображение на ЭЛТ-мониторе, с плохим RAMDAC картинка как-бы «плыла».

Настраиваем окружение

Дабы пилить игры под 3dfx Voodoo, необязательно иметь ПК на WinXP и тулчейн уровня VC98. Строго говоря, даже самой видеокартой обладать необязательно — существует множество эмуляторов Glide, которые перехватывают вызовы игр к GAPI и преобразуют их в вызовы отрисовки D3D или Vulkan, что позволяет отлаживать ваши игры в профайлере.

image

Glide в его нативном виде работает под Windows XP. Поэтому минимальный тулчейн, который можно использовать — это VC2015 с поддержкой Windows XP. Но если вы хотите максимальной «трушности» и иметь возможность запускать софт на Win98/Win95 — нужно использовать Visual Studio 2005. При этом, совершенно необязательно отказываться от плюшек современных IDE: Visual Studio автоматически подхватывает все установленные тулчейны в системе, достаточно лишь выбрать нужный:

image

Далее, по классике добавляем либы, которые без проблем слинкуются относительно современным линкером с вашими бинарниками.

image

Ну что ж, на этом интро-часть материала закончена. Самое время перейти от теории к практике!

Инициализация

Концептуально Glide очень близок к OpenGL, поэтому тем людям, кто имел опыт программирования 3D-графики, сам процесс настройки стейтов и рисования примитивов будет знаком. Однако поскольку основной целью Glide было создание облегченного GAPI, которое только рисует примитивы и управляет текстурными юнитами, мы не найдем здесь utility-функций (в Glide 2.x они были, причем очень даже полезные, но в 3.x gu убрали), таких как работа с матрицами — множество вещей нужно реализовывать самому, полностью с нуля. Однако я постараюсь подробно и понятно всё объяснить, в свойственной мне манере!

image

Наша игра начинается с инициализации и конфигурации контекста Glide, который может быть только один в системе. Связано это с тем, что каждое приложение эксклюзивно занимает ВСЕ ресурсы видеочипа для себя — возможности рисовать в отдельное окно, напоминаю, нет. Инициализация очень простая: сначала нужно проверить количество видеочипов в системе, а затем выбрать один из них как основной и создать контекст.

Параметры нашего контекста: разрешение 640x480 в формате RGBA и частотой обновления 60Гц, два буфера для двойной буферизации и один aux-буфер для Z-буфера.


        int numBoards = 0;
	grGet(GR_NUM_BOARDS, sizeof(numBoards), (FxI32*)&numBoards);

	if (numBoards < 1)
	{
		printf("No Glide GPU is presentn");
                return -1;
	}

	printf("Initializing glide renderern");
	grGlideInit();
	grSstSelect(0);

	gContext = grSstWinOpen(0, GR_RESOLUTION_640x480, GR_REFRESH_60Hz, GR_COLORFORMAT_RGBA, GR_ORIGIN_UPPER_LEFT,
		2, 1); // RGBA, double buffered, 1 depth-buffer

Всё! Ни окон создавать не нужно, ни ловить события через WndProc. Просто создал контекст — и уже можно в цикле продолжать работу. После этого, нам нужно настроить состояние контекста — базовая концепция в программировании графики, которая заключается в том, что у видеокарты есть множество стейтов, которые управляют выводом итоговой картинки на экран. Примеры стейтов: цвет освещения, ширина линий, Z-буфер и Color-буфер.

Для того, чтобы наша игра работала даже на первых видеоускорителях 3dfx, нам необходимо выбрать абсолютную координатную систему, а также настроить Depth-буфер и Cull-mode. Cull-mode, или Backface culling позволяет отсекать примитивы, если они повернуты к камере обратной стороной — это позволяет не рисовать лишние фрагменты. Именно из-за этой техники, залетая внутрь модельки простенького здания или персонажа, внутри она оказывается прозрачной!


        grCoordinateSpace(GR_WINDOW_COORDS);
	grDepthBufferMode(GR_DEPTHBUFFER_ZBUFFER);
	grDepthBufferFunction(GR_CMP_GREATER);
	grDepthMask(FXTRUE);
        grCullMode(GR_CULL_POSITIVE);

Что такое Depth-буфер?

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

При отрисовке следующих примитивов, видеокарта проверяет Z-координату рисуемых треугольников с Z-координатами в буфере и если Z-координата фрагмента больше (т.е объект за другим объектом), видеокарта просто не рисует пиксель.

В классических GAPI принято использовать режимы сравнения LEQUAL и LESS, однако с буфером глубины в Glide есть особенности: в самой видеокарте они хранятся в виде целочисленного 16-битного short (т.е до 65535 значений), причем обратного (1 / z). Поэтому в нашем случае, чем объект дальше от камеры, тем меньше его Z-координаты, а посему для корректной сортировки нужно необходимо выставлять режим сравнения GREATER. Но об этом немного позже — когда дойдем до фактической отрисовки треугольников.

Переходим к настройкам формата вершины.


#define GR_VERTEX_X_OFFSET              0
#define GR_VERTEX_Y_OFFSET              1
#define GR_VERTEX_OOZ_OFFSET            2
#define GR_VERTEX_OOW_OFFSET            3
#define GR_VERTEX_R_OFFSET              4
#define GR_VERTEX_G_OFFSET              5
#define GR_VERTEX_B_OFFSET              6
#define GR_VERTEX_A_OFFSET              7
#define GR_VERTEX_Z_OFFSET              8
#define GR_VERTEX_SOW_TMU0_OFFSET       9
#define GR_VERTEX_TOW_TMU0_OFFSET       10
#define GR_VERTEX_OOW_TMU0_OFFSET       11
#define GR_VERTEX_SOW_TMU1_OFFSET       12
#define GR_VERTEX_TOW_TMU1_OFFSET       13
#define GR_VERTEX_OOW_TMU1_OFFSET       14
#if (GLIDE_NUM_TMU > 2)
#define GR_VERTEX_SOW_TMU2_OFFSET       15
#define GR_VERTEX_TOW_TMU2_OFFSET       16
#define GR_VERTEX_OOW_TMU2_OFFSET       17
#endif

typedef struct {
	float  U;                   /* s texture ordinate (s over w) */
	float  V;                   /* t texture ordinate (t over w) */
}  GrTmuVertex;

typedef struct
{
	float X, Y;         /* X and Y in screen space */
	float Z;          /* 65535/Z (used for Z-buffering) */
	float Q;          /* 1/W (used for W-buffering, texturing) */
	float r, g, b, a;   /* R, G, B, A [0..255.0] */
	GrTmuVertex  tmuvtx[GLIDE_NUM_TMU];
} Vertex;      


        grVertexLayout(GR_PARAM_XY, GR_VERTEX_X_OFFSET << 2, GR_PARAM_ENABLE);
	grVertexLayout(GR_PARAM_RGB, GR_VERTEX_R_OFFSET << 2, GR_PARAM_ENABLE);
	grVertexLayout(GR_PARAM_A, GR_VERTEX_A_OFFSET << 2, GR_PARAM_ENABLE);
	grVertexLayout(GR_PARAM_Z, GR_VERTEX_OOZ_OFFSET << 2, GR_PARAM_ENABLE);
	grVertexLayout(GR_PARAM_Q, GR_VERTEX_OOW_OFFSET << 2, GR_PARAM_ENABLE);
	grVertexLayout(GR_PARAM_ST0, GR_VERTEX_SOW_TMU0_OFFSET << 2, GR_PARAM_ENABLE);
	

Как я уже говорил ранее, 3dfx Voodoo оперирует координатами в абсолютных экранных координатах, про 3D он формально ничего не знает. Создать трёхмерное представление — задача программиста. X и Y — координаты треугольника в экранных координатах, Z-значение — для указания дальности вершины от камеры и сортировки, Q — т. н/ W-координата, необходимая для перспективного деления и перспективно-корректного текстурирования (про второе позже). R, G, B, A — цвет вершины. Позволяет, например, раскрасить ландшафт с помощью комбайнера в несколько проходов, или просто сделать модельку любого цвета, а TmuVertex — UV-координаты для корректного наложения текстур. Подробнее об этом будет в разделе текстур.

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

Это по сути минимальная инициализация Glide для рисования трёхмерной графики. После создания контекста, мы увидим синий экран. Нормальный синий экран/ :)

image

Рисуем примитивы

Теперь переходим к самому интересному — рисованию треугольников! Однако до полноценного 3D-представления нам пока ещё рано. Сначала хотя-бы просто научиться выводить примитивы на экран.
Впрочем, ничего сложного в этом нет.

Перед рисованием геометрии, нам необходимо очистить (т. е. залить одним цветом) бэкбуфер и Depth-буфер. Бэкбуфер необходимо очищать только если ваша сцена не перекрывает весь экран — т. е. в ней есть не закрашенные участки. На современных видеокартах эта операция бесплатная, однако на видеокартах 90х от нее есть некоторый оверхед. Если бэкбуфер не чистить, то при передвижении камеры, мы будем наблюдать т.н «эффект зеркал» — поскольку новая геометрия рисуется поверх старой, то мы будем видеть старый кадр внахлест с новым:


// Buffer clear operations aren't free on Voodoo. When scene will have skybox, comment this line
	grBufferClear(RGB(0, 128, 0), 0, 0);

Треугольники рисуются одной-единственной функций: grDrawTriangle, которая принимает ссылку на три структуры с описанием вершины. Формат вершины мы уже указали при инициализации, поэтому для отрисовки нам нужно лишь заполнить их данными:


Vertex v0;
v0.X = 0;
v0.Y = 0;

Vertex v1;
v1.X = 15;
v1.Y = 0;

Vertex v2;
v1.X = 15;
v1.Y = 15;

grDrawTriangle(&v0, &v1, &v2);

Результат:

image

Откуда же берутся текстуры, окраска и освещение, спросите вы? Может, есть какая-то система материалов как в Unity/UE? Дойдем и до этого, уже совсем скоро!

Уже после отрисовки сцены, нам необходимо поменять бэкбуфер и фронтбуфер местами — таким образом, мы избежим мерцания при отрисовке следующего кадра.

grBufferSwap(1);

Теперь у нас есть треугольник, выведенный средствами 3dfx Voodoo! Уже неплохое начало, но… где обещанное 3D!? Об этом — в следующем разделе!

Математика и трансформации

Приготовьтесь, этот раздел будет звучать как учебник по матану — относительно сложно, но я постарался объяснить как можно проще и понятнее :) Если всё таки окажется сложновато — переходите к коду, понять будет гораздо проще. Строго говоря, по началу вам вообще необязательно знать детали реализации перемножения матриц и самих матриц трансформации, поскольку есть очень удобные математические библиотеки (glm и DXMath, например).

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


Matrix.RotationY(Math.DegToRad(90)) * Matrix.Translation(0, 0, 10);

Мы передвинем персонажа на 10 юнитов и повернем его на 90 градусов по оси Y. Например, компонент Transform в Unity строит мировую матрицу на основе позиции, поворота и масштабирования, которые вы задаете в инспекторе/коде.

image

В 3D-графике есть три основные матрицы, которые отвечают за положение объекта в мире и его представление из глаз наблюдателя:

  • Мировая матрица/матрица модели (OpenGL) — Положение рисуемой геометрии в глобальных мировых координатах. Пример с передвижением и поворотом относится именно к мировой матрице.
  • Матрица вида — Положение камеры в игровом мире. Трансформация камеры в мире имеет инвертированную систему координат — поскольку все передвижения объектов в глазах игрока — это как-бы вычитание позиции камеры из позиции объекта. Это значит, что для соответствия систем координат, все углы поворота и координаты необходимо инвертировать (-x, -y, -z). Умножив мировую матрицу на матрицу вида — мы получим координаты геометрии в пространстве наблюдателя.
  • Матрица проекции — Самая непонятная для некоторых матрица. Именно она преобразует координаты из пространства наблюдателя в Clip-Space (т.е абсолютные координаты треугольников в пространстве окна) и выдает нам W-координату, необходимую для перспективной проекции!

Таким образом, перед тем как быть нарисованной на экране, каждая вершина должна быть умножена на три матрицы — World/Model, View, Projection. Затем, на полученную матрицу необходимо умножить координаты самой вершины (в свою очередь, координаты вершины — это само строение 3D-модели).

$v * (model * view * projection)$

Насколько мне известно, с математической точки зрения это неверно — матрицы могут быть умножены только на матрицы той-же размерности. 4х-мерный вектор матрицой вообще не является, но там используется своя формула. Пожалуй, совсем уж в детали реализации математики не буду — это за рамками содержания статьи, но для общего понимания принципа работы 3D-графики, не только на 3dfx Voodoo, это необходимо.

Переходим к деталям реализации, здесь уже всё гораздо проще. Ниже приведена примитивнейшая, неэффективная и неоптимизированная «матлиба». Ну а что вы хотели, у нас таргет — Pentium MMX, там даже SIMD не было. :) Зато вполне наглядно.



#define MATRIX_SET(matrix, row, column, val) matrix[row * 4 + column] = val
#define MATRIX_GET(matrix, row, column) matrix[row * 4 + column]

void MatrixIdentity(float* matrix)
{
	memset(matrix, 0, sizeof(float) * 16);
	MATRIX_SET(matrix, 0, 0, 1);
	MATRIX_SET(matrix, 1, 1, 1);
	MATRIX_SET(matrix, 2, 2, 1);
	MATRIX_SET(matrix, 3, 3, 1);
}

void MatrixTranslation(float* matrix, float x, float y, float z)
{
	float m2[16];
	MatrixIdentity((float*)&m2);

	MATRIX_SET(m2, 3, 0, x);
	MATRIX_SET(m2, 3, 1, y);
	MATRIX_SET(m2, 3, 2, z);

	MatrixMultiply(matrix, (float*)&m2);
}

void MatrixRotationX(float* matrix, float rot)
{
	float m2[16];
	MatrixIdentity((float*)&m2);

	// TODO: Optimize it
	MATRIX_SET(m2, 1, 1, (float)cos(rot));
	MATRIX_SET(m2, 1, 2, (float)sin(rot));
	MATRIX_SET(m2, 2, 1, -(float)sin(rot));
	MATRIX_SET(m2, 2, 2, (float)cos(rot));

	MatrixMultiply(matrix, (float*)&m2);
}

void MatrixRotationY(float* matrix, float rot)
{
	float m2[16];
	MatrixIdentity((float*)&m2);

	// TODO: Optimize it
	MATRIX_SET(m2, 0, 0, (float)cos(rot));
	MATRIX_SET(m2, 0, 2, -(float)sin(rot));
	MATRIX_SET(m2, 2, 0, (float)sin(rot));
	MATRIX_SET(m2, 2, 2, (float)cos(rot));

	MatrixMultiply(matrix, (float*)&m2);
}

void MatrixRotationZ(float* matrix, float rot)
{
	float m2[16];
	MatrixIdentity((float*)&m2);

	// TODO: Optimize it
	MATRIX_SET(m2, 1, 1, (float)cos(rot));
	MATRIX_SET(m2, 1, 2, (float)sin(rot));
	MATRIX_SET(m2, 2, 1, -(float)sin(rot));
	MATRIX_SET(m2, 2, 2, (float)cos(rot));

	MatrixMultiply(matrix, (float*)&m2);
}

void MatrixRotationAngle(float* matrix, float x, float y, float z)
{
	MatrixRotationY(matrix, y * DEGTORAD);
}

void MatrixPrint(float* matrix)
{
	for (int i = 0; i < 4; i++)
	{
		for (int j = 0; j < 4; j++)
			printf("%f ", matrix[i * 4 + j]);

		printf("n");
	}
}

// FOV - в радианах
void MatrixPerspective(float* matrix, float fov, float aspect, float _near, float _far)
{
	float m2[16];
	MatrixIdentity((float*)&m2);

	float yScale = 1.0f / (float)tan(fov / 2);
	float xScale = yScale / aspect;

	MATRIX_SET(m2, 0, 0, xScale);
	MATRIX_SET(m2, 1, 1, yScale);
	MATRIX_SET(m2, 2, 2, _far / (_far - _near));
	MATRIX_SET(m2, 3, 2, -_near * _far / (_far - _near));
	MATRIX_SET(m2, 3, 3, 0);
	MATRIX_SET(m2, 2, 3, 1);
	// Perspective FOV left-handed matrix

	MatrixMultiply(matrix, (float*)&m2);
}

void MatrixMultiply(float* matrix1, float* matrix2)
{
	float mat1[16];
	memcpy((float*)&mat1, matrix1, sizeof(float) * 16);
        // Первая базовая оптимизация - развернуть цикл. Современный компилятор это сделает сам, но тем не менее. Умножение матриц замечательно ускоряется с помощью SIMD-инструкций.
	for (int j = 0; j < 4; j++) {
		for (int i = 0; i < 4; i++) {
			MATRIX_SET(matrix1, j, i,
				MATRIX_GET(mat1, j, 0) * MATRIX_GET(matrix2, 0, i) +
				MATRIX_GET(mat1, j, 1) * MATRIX_GET(matrix2, 1, i) +
				MATRIX_GET(mat1, j, 2) * MATRIX_GET(matrix2, 2, i) +
				MATRIX_GET(mat1, j, 3) * MATRIX_GET(matrix2, 3, i));
		}
	}
}

И вот, наконец-то мы переходим к отрисовке самой графики!

Рисуем 3D-модель!

Итак, что мы поняли из предыдущих разделов? Первым делом, нам нужно умножить каждую вершину на матрицу ModelViewProjection и исходя из позиционирования в абсолютных координатах, перевести координаты из Clip space в оконные. Ничего сложного!

Но сначала, давайте загрузим модельку. Я быстренько состряпал конвертер из SMD (собственный загрузчик, который таскаю из проекта в проект) в свой формат моделей.

string fileName = args[0];
                SmdMesh model = new SmdMesh(File.OpenRead(fileName));

                Stream output = File.Create(Path.GetFileNameWithoutExtension(fileName) + ".msh");
                BinaryWriter writer = new BinaryWriter(output);

                writer.Write((ushort)Version);
                writer.Write((ushort)(model.Triangles.Count * 3));

                foreach(SmdTriangle tri in model.Triangles)
                {
                    for(int i = 0; i < 3; i++)
                    {
                        writer.Write(tri.Verts[i].Position.X * 0.1f);
                        writer.Write(tri.Verts[i].Position.Y * 0.1f);
                        writer.Write(tri.Verts[i].Position.Z * 0.1f);

                        writer.Write(tri.Verts[i].UV.X);
                        writer.Write(tri.Verts[i].UV.Y);
                    }
                }

                Console.WriteLine("Total {0} vertices", model.Triangles.Count * 3);

                output.Close();

И написал загрузчик. Обратите внимание, что формат вершин для Glide и для ваших вершин должен отличаться!


MeshHeader header;
	memset(&header, 0, sizeof(header));
	FILE* f = fopen(fileName, "rb");
	fread(&header, sizeof(header), 1, f);

	if (header.Version != MESH_VERSION)
	{
		printf("Mesh version mismatch for %sn", fileName);

		return 0;
	}

	Mesh* ret = new Mesh();
	ret->NumVertices = header.NumVertices;
	ret->Vertices = new MeshVertex[header.NumVertices];
	fread(ret->Vertices, sizeof(MeshVertex), ret->NumVertices, f);
	fclose(f);

	return ret;

Теперь, когда у нас есть 3D-модель, её можно нарисовать.
Сначала нам необходимо трансформировать каждую вершину геометрии в Clip-space, матрица — ModelViewProj. Обратите внимание, что на этом этапе, нормальный рендерер реализовывает клиппинг геометрии. У нас его нет, используется аппроксимация, что обязательно будет выливаться в пропадание геометрии, если одна из её вершин уходят «назад» за камеру. Современные видеокарты делают клиппинг аппаратно:


// Project vertex from world space to clip space. matrix - MVP matrix.
__inline bool GraphicsProjectVertex(Vertex* vert, float* matrix)
{
	float m[4][4];
	memcpy(&m, matrix, sizeof(m)); // Temporary
	Vertex ret;

	// We treat vertex as float4(pos, 1)
	ret.X = vert->X  * m[0][0] + vert->Y  * m[1][0] + vert->Z * m[2][0] + (1 * m[3][0]);
	ret.Y = vert->X  * m[0][1] + vert->Y * m[1][1] + vert->Z * m[2][1] + (1 * m[3][1]);
	ret.Z = vert->X  * m[0][2] + vert->Y * m[1][2] + vert->Z * m[2][2] + (1 * m[3][2]);
	ret.Q = vert->X  * m[0][3] + vert->Y * m[1][3] + vert->Z * m[2][3] + (1 * m[3][3]);

	*vert = ret;

	return ret.Q >= 0;
}

После того, как мы подготовили вершины, необходимо преобразовать их из Clip-Space в абсолютные координаты окна и посчитать для них UV в координатной системе 3dfx Voodoo:


__inline bool GraphicsConvertToWindowSpace(Vertex* inV, Vertex* outV, float* matrix)
{
	Vertex v = *inV;
	if (!GraphicsProjectVertex(&v, (float*)matrix)) // Если вершина не проходит в клиппинг - мы не рисуем весь примитив
		return false;

	outV->X = XVALUE_TO_WINDOW_SPACE(v.X / v.Q, 640);
	outV->Y = YVALUE_TO_WINDOW_SPACE(v.Y / v.Q, 480);
	outV->Z = 65535.0f / absF(v.Z);
	outV->Q = 1 / v.Q;

	outV->tmuvtx[0].U = v.tmuvtx[0].U / v.Q;
	outV->tmuvtx[0].V = v.tmuvtx[0].V / v.Q;

	return true;
}

Обратите внимание на то, что каждая координата в Clip-Space делится на координату W — это называется перспективным делением, благодаря которому вершины отдаляются и приближаются в зависимости от позиции относительно глаз игрока! Здесь же рассчитывается координата Z для отрисовки перекрытой геометрии.

Перевод из Clip-Space в абсолютные координаты:


#define XVALUE_TO_WINDOW_SPACE(srcX, width) (width / 2) + (srcX * width);
#define YVALUE_TO_WINDOW_SPACE(srcY, width) width - ((width / 2) + (srcY * width));

Результат (обратите внимание, что треугольник оказался в центре экрана и уменьшился в размерах — это и есть эффект от перспективного деления и перевода из Clip-space, где -1 — левая часть окна, 0 — центр, 1 — правая часть):

image

И теперь, мы наконец-то, готовы нарисовать 3D-модель! Обратите внимание, что UV-координаты переводятся из 0..1 в 0..255 из-за особенностей TMU.


void GraphicsDrawTriangle(MeshVertex* verts, float* matrix)
{
	if (verts)
	{
		// Convert generic vertex format to Glide format first
		Vertex v[3];
		memset(&v, 0, sizeof(v));

		for (int i = 0; i < 3; i++)
		{
			v[i].X = verts[i].Position.X;
			v[i].Y = verts[i].Position.Y;
			v[i].Z = verts[i].Position.Z;
			v[i].tmuvtx[0].U = verts[i].UV.X * 255.0f;
			v[i].tmuvtx[0].V = verts[i].UV.Y * 255.0f;
		}

		Vertex v1, v2, v3;
		if (!GraphicsConvertToWindowSpace(&v[0], &v1, matrix))
			return;

		if (!GraphicsConvertToWindowSpace(&v[1], &v2, matrix))
			return;

		if (!GraphicsConvertToWindowSpace(&v[2], &v3, matrix))
			return;

		grDrawTriangle(&v1, &v2, &v3);
	}
}

void GraphicsDrawMeshEx(Mesh* mesh, Material* material, float* matrix)
{
	if (mesh && mesh->NumVertices > 0)
	{
		// Setup render state
		GraphicsSetupTextureStage(material);

		for (int i = 0; i < mesh->NumVertices / 3; i++)
		{
			GraphicsDrawTriangle(&mesh->Vertices[i * 3], matrix);
		}
	}
}

...

float matrix[16];
MatrixIdentity((float*)&matrix);
MatrixRotationAngle((float*)&matrix, 0, rot, 0);
MatrixTranslation((float*)&matrix, rot, 0, 15);
MatrixPerspective((float*)&matrix, 90 * 0.0174533f, 640.0f / 480, 1, 100);

GraphicsDrawMeshEx(mesh, &mat, (float*)&matrix);

Результат:

image

Скрин с криво-наложенной текстурой: здесь уже был настроен сэмплер, но неправильно. А ещё тут видно артефакты отсутствия Z-сортировки наглядно.

Пока не впечатляет, да? Где же текстуры? А об этом — в следующей главе!

Текстуры

Плоские модельки без текстур — это не очень круто. Ну что можно сделать с плоскими моделями, пусть даже они будут с освещением?

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

Но в Glide всё было иначе — ведь там не было понятия текстуры как объекта! Как-так? Glide позволял нам получить верхнюю и нижнюю границу адресного пространства памяти одного конкретного TMU и программист волен был выгружать текстуру куда угодно! Затем программист сохранял указатель на текстуру в видеопамяти и передавал её… в комбайнер, дабы он мог использовать текстуру по назначению! При этом TMU даже характеристики текстур не знал — эту информацию отсылал программист.

TMU поддерживает множество пиксельформатов: RGB332 (8 бит на пиксель), RGB565 (16 бит на пиксель), палитровый и собственный формат сжатия с NCC-компрессией. Тем не менее, 565 у 3dfx требует какого-то особого формата пикселей, иначе текстуры превращаются в кашу. Благо для загрузки текстур с диска, в Glide есть удобные функции и тулза texUS для создания текстур и всего набора мипов для них — gu3dfGetInfo и gu3dfLoad. Кроме того, есть функция grTexCalcMemRequired для расчета необходимого размера для текстуры в видеопамяти с учетом мипов, формата и выравнивания.

Я не стал писать сложный аллокатор, поскольку игра не требует динамической видеопамяти и может сразу загрузить уровень «пачкой», а при загрузке следующего — просто освободить всю память.


        gTextureHeapMin = grTexMinAddress(GR_TMU0);
	gTextureHeapMax = grTexMaxAddress(GR_TMU0);

        Gu3dfInfo info;
	gu3dfGetInfo(fileName, &info);

	FxU32 required = grTexCalcMemRequired(info.header.small_lod, info.header.large_lod, info.header.aspect_ratio, info.header.format);

	if (gTextureHeapMin + gTextureHeapUsage + required > gTextureHeapMax)
	{
		printf("Failed to allocate texture %s: VRAM exhaustedn", fileName);
		exit(-1);
	}

	FxU32 linearAddr = gTextureHeapMin + gTextureHeapUsage;

	info.data = malloc(info.mem_required);
	gu3dfLoad(fileName, &info);

После этого, нам необходимо загрузить текстуру в видеопамять юнита с помощью grTexDownloadMipMap.


        GrTexInfo tInfo;
	tInfo.aspectRatioLog2 = info.header.aspect_ratio;
	tInfo.smallLodLog2 = info.header.small_lod;
	tInfo.largeLodLog2 = info.header.large_lod;
	tInfo.format = info.header.format;
	tInfo.data = info.data;

	grTexDownloadMipMap(0, linearAddr, GR_MIPMAPLEVELMASK_BOTH, &tInfo);

Как же теперь указать текстуру для сэмплинга, ведь glBindTexture здесь нет? Для этого есть функция glTexSource, которая принимает адрес первого мипа и конфигурацию текстуры — которая хранится на стороне ЦПУ!

grTexSource(0, linearAddr, GR_MIPMAPLEVELMASK_BOTH, &tInfo);

	NativeTextureHandle* tex = new NativeTextureHandle();
	tex->TexInfo = tInfo;
	tex->LinearAddr = linearAddr;

	Texture* ret = new Texture();
	ret->Width = 0;
	ret->Height = 0;
	ret->NativeHandle = tex;

Но если мы сейчас запустим программу, то никакой текстуры мы не увидим. Потому что сначала нужно настроить сэмплер и комбайнеры!

Для этого, мы настраиваем комбайнеры на сэмплинг текстур напрямую, без умножения на цвет вершины. Альфа-канал мы не трогаем вообще — у нас нет альфа-буфера для него.

grColorCombine(GR_COMBINE_FUNCTION_SCALE_OTHER,
		GR_COMBINE_FACTOR_ONE,
		GR_COMBINE_LOCAL_NONE,
		GR_COMBINE_OTHER_TEXTURE,
		FXFALSE);
	grAlphaCombine(GR_COMBINE_FUNCTION_SCALE_OTHER,
		GR_COMBINE_FACTOR_ONE,
		GR_COMBINE_LOCAL_NONE,
		GR_COMBINE_OTHER_TEXTURE,
		FXFALSE);
	grAlphaBlendFunction(GR_BLEND_SRC_ALPHA,
		GR_BLEND_ONE_MINUS_SRC_ALPHA,
		GR_BLEND_ZERO,
		GR_BLEND_ZERO);

Запускаем программу и вот результат:

image

Да, первая полноценная 3D-модель с текстурой! Мы реализовали половину работы видеодрайвера вручную, однако по концовке всё равно очень приятно! Игры здесь пока ещё нет — материал вышел бы слишком большим.

А где практика?

К сожалению, сегодня без практической части :( Изначально я думал что у меня всё схвачено и моя материнка на 478 с AGP-слотом вполне справиться с ролью тестового стенда для нашей демки. Однако, я не учел важный факт — существовало несколько физических версий AGP и на 478 уже поздний вариант с 1.5в/0.8в уровнями.

Материал был обещан в четверг на 11 утра, времени на заказ через почту у меня не было (новый год, посылки 1.5-2 недели задерживаются только на сортировке в Краснодаре), поэтому я начал написывать всем сервисникам у себя в городе, в надежде что у кого-то лежит на складе материнка на PGA370… в любом состоянии.

И материнка нашлась! Ей оказалась поздняя ECS P6IPAT на 815 чипсете с универсальным AGP-разъемом, который поддерживал все стандарты AGP одновременно. Продавал её мужичок всего за 100 рублей, сразу с процом и охладом :) Однако возникли определенные проблемы с поднятием платы (все электролиты необходимо менять «вкруг», а нужных номиналов под рукой не оказалось, плата стартовала раза с 3го) и накатыванием винды (помер IDE-привод), поэтому практическая часть немного откладывается…

image

Заключение

И мы приходим к выводам, что для написания 3D-игры, программист в 90-х годах должен был как минимум:

  1. Иметь представлении о трансформации геометрии, что такое матрицы (геометрию можно трансформировать и без матриц, однако это не очень удобно).
  2. Понимать, как работает конвейер видеокарты, что такое стейты, комбайнеры, каким образом происходит управление памятью, организация фреймбуфера и Depth-буфера.
  3. Иметь представление об основных техниках в растеризации 3D-графики: что такое перспективное деление, Z-буфер, форматы вершин, фильтрация текстур, мипмаппинг, затенение по Гуро, какие-либо методы анимации, если была необходимость и т. п.

Материал получился очень объёмным, для меня это абсолютный рекорд. Я старался собрать всю информацию о 3dfx Voodoo, которую изучил и поделится с вами не только архитектурой конкретно видеокарт, но и рассказать о программировании графических API на низком уровне и подробно рассказать, как же строится изображение «под капотом» и вашей видеокарты.

Касательно баек насчет 3dfx в СНГ: я лично родился в 2001 году, так что могу судить исключительно из услышанных мной баек и историй. А какая история с 3dfx Voodoo была у вас? Пишите в комментах!
Надеюсь, материал был вам интересен. :) Статья писалась несколько бессонных ночей, дабы успеть под новый год! Больше бэкстейджа, мыслей и проектов у меня в Telegram.


Автор: Богдан

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js