[Лонг] Сам написал, сам полетал: как и зачем я разработал 3D-игру с нуля под компьютеры из 90-х в 2024 году?

[Лонг] Сам написал, сам полетал: как и зачем я разработал 3D-игру с нуля под компьютеры из 90-х в 2024 году?

Статьи о разработке инди-игр — это всегда интересно. Но разработка чего-то абсолютно с нуля, без каких-либо движков или фреймворков — ещё интереснее! Почти всю свою жизнь, буквально с 13-14 лет меня тянет пилить какие-нибудь прикольные 3D-демки и игрушки. Ещё на первом курсе ПТУ я написал небольшую демку с 3D-вертолетиками по сети и идея запилить какие-нибудь прикольные леталки не покидала меня по сей день! Спустя 6 лет, в 22 года я собрался с силами и решил написать небольшую аркадную демку про баталии на самолетиках, да так, чтобы работало аж на видеокартах из 90-х — NVidia Riva 128 и 3DFX Voodoo 3! Интересно, как происходит процесс разработки игры с нуля — от первого «тридэ» треугольника, до работающей на реальном железе демки? Тогда добро пожаловать под кат!

❯ Мотивация

Друзья! Вижу, что вам очень заходит моя постоянная рубрика о том, как работали графические ускорители из 90-х «под капотом», где мы не только разбираем их архитектуру, но и пишем демки на их собственных графических API. Мы уже успели с вами рассмотреть 3Dfx Voodoo, S3 ViRGE и мобильный PowerVR MBX и, думаю, теперь пришло время рассмотреть инструменты для разработчиков игр под Windows из 90-х. Про «старый» OpenGL рассказывать смысла не вижу — до сих пор многие новички учатся по материалам с glBegin/glEnd и FFP (Fixed Function Pipeline), а спецификацию с описанием первой версии API можно найти прямо на сайте Khronos. Зато про «старый» DirectX информации в сети очень мало и большинство документации уже потёрли даже из MSDN, хотя в нём было много чего интересного!

[Лонг] Сам написал, сам полетал: как и зачем я разработал 3D-игру с нуля под компьютеры из 90-х в 2024 году?

Вероятно читатель спросит — зачем пилить что-то для компьютеров 90-х годов, если большинство таких машин (к сожалению) отправились на цветмет и «никто в своем уме» не будет ими пользоваться? Ну, ретро-компьютинг и программирование демок — это, во-первых, всегда интересно. Среди моих подписчиков довольно много ребят, которые ещё учатся в школе, а уже натаскали с барахолок Pentium III или Pentium IV и GeForce 4 MX440 и сидят, балдеют и играют в замечательные игрушки из нулевых на таких машинах с по настоящему трушным опытом, да и я сам таким был и остаюсь по сей день. Вон, мне даже dlinyj скидывал свои девайсы в личку, а я сидел и слюни пускал. Так что факт остаётся фактом — ретро-компьютинг становится всё более и более популярен — что не может не радовать!

[Лонг] Сам написал, сам полетал: как и зачем я разработал 3D-игру с нуля под компьютеры из 90-х в 2024 году?

А во-вторых — это челлендж для самого себя! Посмотреть на то, как делали игры «деды» и попытаться запилить что-то самому, не забыв об этом написать статью и снять интересное видео в попытке донести это как можно большему числу читателей и зрителей! Конечно сам DirectX6 в целом значительно проще DX12, но некоторые техники весьма заковыристые и для достижения оптимальной производительности приходится пользоваться хаками. Ну а почему именно леталки? Потому что, наверное, хотел бы когда-нибудь полетать :)

[Лонг] Сам написал, сам полетал: как и зачем я разработал 3D-игру с нуля под компьютеры из 90-х в 2024 году?

Игру я решил писать на C#. Кому-то решение может показаться странным, но я уже не раз говорил, что это мой любимый язык, а при определенной сноровке — программы на нем работают даже под Windows 98. В качестве основного API для игры я выбрал DirectX 6, который вышел 7 августа 1998 года — за 3 года до моего рождения :)Перед тем как что-то начинать делать, нужно определиться с тем, что нам нужно для нашей 3D-игры:

  • Графический движок или рендерер, работающий на базе Direct3D. В его задачи входит отрисовка геометрии, работа с освещением и материалами, отсечение моделей, находящихся вне поле зрения глаз, генерация ландшафтов из карт высот и т. п. Собственно, в нашем конкретном случае это графическим движком назвать сложно — никакого полноценного графа (иерархической структуры, как в Unity) сцены нет, толковой анимации тоже, зато есть довольно продвинутая система материалов :)
  • Звуковой движок на базе DirectSound. Здесь всё по классике: программный 3D-звук с эффектами типа «виу» и «вжух» с загрузкой звуковых дорожек из wav-файлов. Никакого стриминга звука с кольцевыми буферами и ogg/mp3 здесь не нужно!
  • Подсистема ввода, которая представляет из себя «получить состояние кнопки на клавиатуре» и «получить позицию курсора» :) В более продвинутых случаях есть необходимость абстрагирования осей геймпада, ремаппинга кнопок и прочих подобных штук, но в нашей демки необходимости в этом нет.
  • Остальные модули — сюда входят алгоритмы расчёта коллизий, математическая библиотека для работы с векторами и матрицами, система игровых объектов и загрузчики ресурсов. Это весьма небольшие и легкие в реализации подсистемы, но писать про каждый отдельный пункт смысла не очень много, поскольку они так или иначе часть других систем.

Игра будет представлять из себя аркадную 3D-леталку без намека на реалистичность, где мы должны будем управлять самолётиком и отстреливать вражеские самолеты и спавнящиеся время от времени «стреляющие» башни (зенитками назвать это сложно), чтобы они не разрушили нашу базу. Такой вот Battle City в воздухе! Сама игра идёт на очки, никакой конкретной миссии в ней нет, но сложность постепенно растёт. Самолеты и текстуры — первые что попались в интернете с минимальной доработкой (пережатие текстур и упрощение геометрии). Вот и весь «диздок» :)

Как известно, в самолёте всё зависит от винта! Ну, или в нашем случае, от 3D-движка — поэтому предлагаю рассмотреть архитектуру нашего рендерера и заложить первые кирпичики в нашу 3D-игру!

❯ Графический движок

Поскольку C# — управляемый язык и напрямую дёргать COM-интерфейсы формально не может, а готовых обёрток для DirectX 6 по понятным причинам нет, мне пришлось писать свою. Простыми словами, обёртка обеспечивает слой совместимости между нативными библиотеками, написанными на C++ и управляемым кодом, написанном на C#/VB и т.п. Благо в мире .NET есть такое замечательное, но увы, забытое расширение плюсов, как С++/CLI, которое позволяет прозрачно смешивать нативный код и «байткод» .NET, благодаря которому разработка пошла значительно быстрее.

[Лонг] Сам написал, сам полетал: как и зачем я разработал 3D-игру с нуля под компьютеры из 90-х в 2024 году?

Любой графический движок начинается с создания окна и инициализации контекста графического API (инициализации видеокарты, если простыми словами) для рисования в это самое окно. В случае Direct3D6 всё интереснее тем, что фактически здесь уже был свой аналог современного DXGI (DirectX Graphics Infrastructure — библиотека для управления видеокартами, мониторами в системе), который назывался DirectDraw. Изначально DDraw использовался для аппаратного ускорения графики на VGA 2D-акселеллераторах — тех самых S3 ViRGE и Oak Technology и предназначался в основном для операций блиттинга (копирования картинки в картинку), но в D3D ему выделили функции управления видеопамятью и поэтому они очень тесно связаны.

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

Guard(DirectDrawCreate(0, &dd, 0)); ddraw = dd; Guard(ddraw->SetCooperativeLevel(hwnd, DDSCL_NORMAL)); // Create primary surface DDSURFACEDESC desc; memset(&desc, 0, sizeof(desc)); desc.dwSize = sizeof(desc); desc.dwFlags = DDSD_CAPS; desc.ddsCaps.dwCaps = DDSCAPS_PRIMARYSURFACE; desc.dwBackBufferCount = 1; Guard(ddraw->CreateSurface(&desc, &pSurf, 0)); Guard(pSurf->QueryInterface(IID_IDirectDrawSurface4, (LPVOID*)&pSurf4)); primarySurface = pSurf4; DDPIXELFORMAT pf; pSurf->GetPixelFormat(&pf); // Create RT. Since primary surface is always covers all screen, back buffer should be of real size DDSURFACEDESC rtDesc; memset(&rtDesc, 0, sizeof(rtDesc)); rtDesc.dwSize = sizeof(rtDesc); rtDesc.dwFlags = DDSD_CAPS | DDSD_WIDTH | DDSD_HEIGHT; rtDesc.ddsCaps.dwCaps = DDSCAPS_OFFSCREENPLAIN | DDSCAPS_3DDEVICE; rtDesc.dwWidth = Width; rtDesc.dwHeight = Height; Guard(ddraw->CreateSurface(&rtDesc, &sSurf, 0)); Guard(sSurf->QueryInterface(IID_IDirectDrawSurface4, (LPVOID*)&sSurf4));

Теперь у нас есть окно, куда можно что-нибудь нарисовать!

[Лонг] Сам написал, сам полетал: как и зачем я разработал 3D-игру с нуля под компьютеры из 90-х в 2024 году?

Но 3D мы пока рисовать не можем — ведь контекста D3D у нас всё ещё нет, благо создаётся он очень просто. Единственный момент: Z-буфер нужно создать перед созданием устройства, иначе работать он не будет.

Guard(ddraw->QueryInterface(IID_IDirect3D3, (LPVOID*)&d3d)); // Enumerate and pick best Z-Buffer format Guard(d3d->EnumZBufferFormats(IID_IDirect3DHALDevice, OnDepthStencilFormatSearchCallback, 0)); // Create Z-Buffer for this device DDSURFACEDESC zbufDesc; memset(&zbufDesc, 0, sizeof(zbufDesc)); zbufDesc.dwSize = sizeof(zbufDesc); zbufDesc.dwFlags = DDSD_CAPS | DDSD_WIDTH | DDSD_HEIGHT | DDSD_PIXELFORMAT; zbufDesc.ddsCaps.dwCaps = DDSCAPS_ZBUFFER | DDSCAPS_VIDEOMEMORY; memcpy(&zbufDesc.ddpfPixelFormat, Window::zBufferFormat, sizeof(zbufDesc.ddpfPixelFormat)); zbufDesc.dwWidth = Width; zbufDesc.dwHeight = Height; IDirectDrawSurface* zTemp; IDirectDrawSurface4* zSurface; Guard(ddraw->CreateSurface(&zbufDesc, &zTemp, 0)); Guard(zTemp->QueryInterface(IID_IDirectDrawSurface4, (LPVOID*)&zSurface)); // Attach Z-Buffer to backbuffer Guard(d3dSurface->AddAttachedSurface(zSurface)); Guard(d3d->CreateDevice(IID_IDirect3DHALDevice, surf, &device, 0));

Мы уже на полпути перед тем как нарисовать первый тридэ-треугольник: осталось лишь объявить структуру вершины и написать обёртки над… Begin/End! Да, в Direct3D когда-то тоже была концепция из OpenGL, а связана она с тем, что в видеокартах тех лет вершины передавались не буферами, а по одному, уже трансформированные. Подробнее об этом можно почитать в моей статье о S3 ViRGE:

public value struct Vertex { public: float X, Y, Z; float NX, NY, NZ; D3DCOLOR Diffuse; float U, V; }; ... Vertex[] v = new Vertex[3]; v[0] = new Vertex() { X = 0, Y = 0, Z = 0, U = 0, V = 0 }; v[1] = new Vertex() { X = 1, Y = 0, Z = 0, U = 1, V = 0 }; v[2] = new Vertex() { X = 1, Y = 1, Z = 0, U = 1, V = 1 }; dev.BeginScene(); dev.Begin(PrimitiveType.TriangleList, Device.VertexFormat); dev.Vertex(v[0]); dev.Vertex(v[1]); dev.Vertex(v[2]); dev.End(); dev.EndScene();

И вот, у нас есть первый треугольник! Читатель может спросить — а где же здесь игра и причём здесь треугольники, мы же не на уроке геометрии… Дело в том, что вся 3D-графика в современных играх строится из треугольников. Любая моделька на экране — это набор из маленьких примитивов, которые в процессе рисования на экран подвергаются процессу трансформации — преобразованию из мировых координат (то есть абсолютной позиции в мире) сначала в координаты камеры (таким образом, при движении камеры, на самом деле двигаются объекты вокруг камеры), а затем и в экранные координаты (NDC), где происходит перспективное деление и каждый треугольник начинает выглядеть как трёхмерный. После этого, NDC координаты переводятся в оконные и треугольник рисуется по преобразованным координатам на дисплее.

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

[Лонг] Сам написал, сам полетал: как и зачем я разработал 3D-игру с нуля под компьютеры из 90-х в 2024 году?

Но если сейчас нарисовать самолетик, то он будет исключительно белым, без намёка на освещение или детали. А для его «раскрашивания» служат текстуры — специальные изображения, подогнанные под текстурные координаты геометрии, которые помогают дополнить образ 3D-моделей деталями: асфальт на дороге, трава на земле, дверная карты в жигулях…

[Лонг] Сам написал, сам полетал: как и зачем я разработал 3D-игру с нуля под компьютеры из 90-х в 2024 году?

И вот с текстурами ситуация в D3D6 не менее интересная и очень похожа на современные GAPI: нам необходимо сначала создать текстуру в системной памяти (ОЗУ) и только затем скопировать её в видеопамять. Причём форматов текстур не слишком много. Я выбрал RGB565 (16-битный), хотя есть поддержка и форматов со сжатием — тот-же S3TC.

bool hasMips = mipCount > 1; // If texture has more than 1 mipmap, then create surface as complex, if not - then as single-level. DDSURFACEDESC2 desc; memset(&desc, 0, sizeof(desc)); desc.dwSize = sizeof(desc); desc.dwFlags = DDSD_CAPS | DDSD_WIDTH | DDSD_HEIGHT | DDSD_PIXELFORMAT | DDSD_TEXTURESTAGE | DDSD_CKSRCBLT; desc.ddsCaps.dwCaps = DDSCAPS_TEXTURE | (hasMips ? (DDSCAPS_MIPMAP | DDSCAPS_COMPLEX) : 0); desc.ddsCaps.dwCaps2 = DDSCAPS2_TEXTUREMANAGE; desc.ddckCKSrcBlt.dwColorSpaceHighValue = 0; desc.ddckCKSrcBlt.dwColorSpaceLowValue = 0; memcpy(&desc.ddpfPixelFormat, DXSharp::Helpers::Window::opaqueTextureFormat, sizeof(desc.ddpfPixelFormat)); desc.dwWidth = Width = width; desc.dwHeight = Height = height; IDirectDrawSurface4* surf; IDirect3DTexture2* tex; IDirectDraw4* dd2; window->ddraw->QueryInterface(IID_IDirectDraw4, (LPVOID*)&dd2); Guard(dd2->CreateSurface(&desc, &surf, 0)); Guard(surf->QueryInterface(IID_IDirect3DTexture2, (LPVOID*)&tex));

А чтобы её использовать, нужно «сказать» об этом видеокарте с помощью биндинга текстуры к текстурному юниту. Те, у кого были в свое время 3dfx Voodoo 1-2 с числом TMU, отличавшимся от карты к карте, наверняка поймут, о чём я :)

Guard(device->SetTexture(stage, tex->texture));

И вот у нас уже есть треугольник с текстурой!

[Лонг] Сам написал, сам полетал: как и зачем я разработал 3D-игру с нуля под компьютеры из 90-х в 2024 году?

На одном треугольнике далеко не уедешь, поэтому далее мы реализовываем простенький загрузчик моделей из формата SMD (как в движке GoldSrc), который грузит статичные модельки без скиннинга, а также загрузчик текстур из bmp, и вот — мы уже имеем 3D-модельку самолёта с текстурой.

[Лонг] Сам написал, сам полетал: как и зачем я разработал 3D-игру с нуля под компьютеры из 90-х в 2024 году?

Возможно в каких-то играх и не нужно небо, но в леталках — уж точно необходимо. И без учёта динамических облаков, здесь есть две популярные техники:

  • Sky-sphere, которая заключается в том, что небо представляет из себя полусферу с наложенной поверх текстурой неба в панорамном виде. Такую полусферу очень часто крутят вокруг своей оси по оси Y, создавая эффект плывущих облаков, благодаря чему получается вполне симпатичное анимированное небо. Иные варианты включают в себя многослойные реализации, где крутится могут лишь облака, когда статичные элементы фона, такие как солнце, остаются на месте.На скриншоте можно увидеть реализацию Sky-sphere. Возможно, если вы когда-то улетали в играх «за карту», видели подобную картину :)
[Лонг] Сам написал, сам полетал: как и зачем я разработал 3D-игру с нуля под компьютеры из 90-х в 2024 году?
  • Skybox — здесь суть простая, вокруг камеры рисуется «коробка» с вывернутыми в обратную сторону треугольниками, на которых рисуется текстура одной из сторон панорамы с выключенной записью в Z-буфер. Получается не только симпатично, но ещё и иногда быстрее чем Skysphere на слабом железе, однако техника ощутимо дороже с точки зрения потребления видеопамяти. Скайбоксы можно найти почти везде: например, в HL2, Need For Speed: Most Wanted и Hitman: Codename 47.На скриншоте ниже можно увидеть пример скайбокса:
[Лонг] Сам написал, сам полетал: как и зачем я разработал 3D-игру с нуля под компьютеры из 90-х в 2024 году?

Поскольку у меня леталка, я решил выбрать скайбоксы. Реализация — проще пареной репы. Не забываем отключить тест глубины:

materials[0].Texture = TextureLoader.LoadFromImage(string.Format("{0}{1}_bk.bmp", Path, name)); materials[1].Texture = TextureLoader.LoadFromImage(string.Format("{0}{1}_ft.bmp", Path, name)); materials[2].Texture = TextureLoader.LoadFromImage(string.Format("{0}{1}_lf.bmp", Path, name)); materials[3].Texture = TextureLoader.LoadFromImage(string.Format("{0}{1}_rt.bmp", Path, name)); materials[4].Texture = TextureLoader.LoadFromImage(string.Format("{0}{1}_up.bmp", Path, name)); materials[5].Texture = TextureLoader.LoadFromImage(string.Format("{0}{1}_dn.bmp", Path, name)); .... Engine.Current.Graphics.DrawMesh(mesh, 0, 6, v, new Vector3(0, 0, 0), new Vector3(1, 1, 1), materials[1]); // Forward Engine.Current.Graphics.DrawMesh(mesh, 6, 12, v, new Vector3(0, 0, 0), new Vector3(1, 1, 1), materials[3]); // Right Engine.Current.Graphics.DrawMesh(mesh, 12, 18, v, new Vector3(0, 0, 0), new Vector3(1, 1, 1), materials[0]); // Back Engine.Current.Graphics.DrawMesh(mesh, 18, 24, v, new Vector3(0, 0, 0), new Vector3(1, 1, 1), materials[2]); // Left Engine.Current.Graphics.DrawMesh(mesh, 24, 30, v, new Vector3(0, 0, 0), new Vector3(1, 1, 1), materials[4]); // Up
[Лонг] Сам написал, сам полетал: как и зачем я разработал 3D-игру с нуля под компьютеры из 90-х в 2024 году?

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

[Лонг] Сам написал, сам полетал: как и зачем я разработал 3D-игру с нуля под компьютеры из 90-х в 2024 году?

В процессе загрузки уровня, мы строим из сэмплов карты высот треугольную сетку, где высота каждой вершины определяется соседними пикселями. На практике это выглядит так:

for (int i = 1; i < bmp.Width - 1; i++) { for(int j = 1; j < bmp.Height - 1; j++) { float baseX = (float)i * XZScale; float baseZ = (float)j * XZScale; // Transform vertices verts[vertOffset] = new DXSharp.D3D.Vertex() { X = baseX, Y = ((float)bmp.GetPixel(i, j).R / 255.0f) * YScale, Z = baseZ, U = 0, V = 1 * TextureScale, NY = 1 }; verts[vertOffset + 2] = new DXSharp.D3D.Vertex() { X = baseX, Y = ((float)bmp.GetPixel(i, j + 1).R / 255.0f) * YScale, Z = baseZ + XZScale, U = 0, V = 0, NY = 1 }; verts[vertOffset + 1] = new DXSharp.D3D.Vertex() { X = baseX + XZScale, Y = ((float)bmp.GetPixel(i + 1, j + 1).R / 255.0f) * YScale, Z = baseZ + XZScale, U = 1 * TextureScale, V = 0, NY = 1 }; verts[vertOffset + 3] = new DXSharp.D3D.Vertex() { X = baseX, Y = ((float)bmp.GetPixel(i, j).R / 255.0f) * YScale, Z = baseZ, U = 0, V = 1 * TextureScale, NY = 1 }; verts[vertOffset + 4] = new DXSharp.D3D.Vertex() { X = baseX + XZScale, Y = ((float)bmp.GetPixel(i + 1, j).R / 255.0f) * YScale, Z = baseZ, U = 1 * TextureScale, V = 1 * TextureScale, NY = 1 }; verts[vertOffset + 5] = new DXSharp.D3D.Vertex() { X = baseX + XZScale, Y = ((float)bmp.GetPixel(i + 1, j + 1).R / 255.0f) * YScale, Z = baseZ + XZScale, U = 1 * TextureScale, V = 0, NY = 1 }; vertOffset += 6; } }

А результат — такой! Выше описан самой простой кейс с Terrain'ом: в реальных играх, где ландшафт достаточно большой, его обычно бьют на так называемые патчи и дальние участки ландшафта упрощают с помощью специальных алгоритмов. Таким образом построены ландшафты, например, в TES Skyrim.

[Лонг] Сам написал, сам полетал: как и зачем я разработал 3D-игру с нуля под компьютеры из 90-х в 2024 году?

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

[Лонг] Сам написал, сам полетал: как и зачем я разработал 3D-игру с нуля под компьютеры из 90-х в 2024 году?

Этот способ даёт возможность использовать всего лишь две текстуры за один проход, а в современных играх используется сплат-маппинг, позволяющий использовать более 4х-текстур на одном ландшафте за один проход!

Context.SetTextureStageState(1, (int)TextureStageState.AlphaOp, (int)TextureStageOp.Modulate); Context.SetTextureStageState(1, (int)TextureStageState.AlphaArg1, (int)TextureArgument.Texture); Context.SetTextureStageState(1, (int)TextureStageState.AlphaArg2, (int)TextureArgument.Texture); Context.SetTextureStageState(0, (int)TextureStageState.ColorOp, (int)TextureStageOp.SelectArg1); Context.SetTextureStageState(0, (int)TextureStageState.ColorArg1, (int)TextureArgument.Texture); Context.SetTextureStageState(0, (int)TextureStageState.ColorArg2, (int)TextureArgument.Texture); Context.SetTextureStageState(1, (int)TextureStageState.ColorOp, (int)TextureStageOp.BlendDiffuseAlpha); Context.SetTextureStageState(1, (int)TextureStageState.ColorArg1, (int)TextureArgument.Texture); Context.SetTextureStageState(1, (int)TextureStageState.ColorArg2, (int)TextureArgument.Current);
[Лонг] Сам написал, сам полетал: как и зачем я разработал 3D-игру с нуля под компьютеры из 90-х в 2024 году?
[Лонг] Сам написал, сам полетал: как и зачем я разработал 3D-игру с нуля под компьютеры из 90-х в 2024 году?

Однако текстуры вдали выглядят слишком грубо и отдают пикселями. Для решения этой проблемы придумана техника, которая называется MIP-mapping. Суть простая: исходная текстура делится на несколько небольших, образуя MIP-Chain, где каждый MIP-уровень в два раза меньше чем предыдущий. Таким образом, для текстуры размером в 64x64, цепочка мипов будет выглядеть так:

  • 0 - 64ч64
  • 1 - 32x32
  • 2 - 16x16
  • 3 - 8x8
  • 4 - 4x4
  • 5 - 2x2
  • 6 - 1x1

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

Далее я решил "посадить" деревья: в ретро-играх растительность нередко представляла из себя один или два прямоугольника, на который наносится текстура дерева или куста. На скриншоте ниже это можно четко проследить:

[Лонг] Сам написал, сам полетал: как и зачем я разработал 3D-игру с нуля под компьютеры из 90-х в 2024 году?

Но... ведь наши деревья чёрные! А всё потому, что у них нет альфа-канала, благодаря которому видеокарта может отделить прозрачные пиксели текстуры от непрозрачных. Полноценный альфа-блендинг (полупрозрачность) на ретро видеокартах была слишком дорогой, поэтому приходится использовать технику, называемую колоркеями (Color key). Техника очень схожа с Chromakey, благодаря которой вырезают фон из видео, но в отличии от неё, Colorkey оперирует точными значениями цвета без дополнительного разброса. Если говорить простыми словами, то у нас есть определенный цвет, который считается полностью прозрачным и не используется во всей картинке. На этапе сэмплинга текстуры, GPU проверяет соответствие цвета колоркею и если они совпадают - полностью отбрасывает текущий фрагмент.

Чаще всего цветом ColorKey считается Magenta, однако я решил выбрать полностью чёрный:

[Лонг] Сам написал, сам полетал: как и зачем я разработал 3D-игру с нуля под компьютеры из 90-х в 2024 году?

После включения колоркея, наши деревья приняли вполне классический вид:

[Лонг] Сам написал, сам полетал: как и зачем я разработал 3D-игру с нуля под компьютеры из 90-х в 2024 году?

Упс, а FPS то успел просесть с 1.000 до 50 из-за большого количества DIP'ов и полного несоответствия архитектуры современных GPU с видеокартами из 90-х (весь FFP конвейер эмулируется на шейдерах).

Одной из самых базовых и эффективных оптимизаций считается Frustum culling, также известный как отсечение по пирамиде видимости. Суть алгоритма простая: из матрицы вида и проекции строятся 6 плоскостей, каждая из которых описывает одну из сторон системы координат: левая, правая, верхняя, нижняя, ближняя и дальняя. Таким образом, делая обычную проверку нахождения точки в World-space и одной из плоскостей, мы можем отсечь невидимую глазу геометрию и не прожигать циклы GPU и CPU впустую:

public void Calculate(Matrix viewProj) { float[] items = viewProj.Items; Planes[0] = new Vector4(items[3] - items[0], items[7] - items[4], items[11] - items[8], items[15] - items[12]); Planes[0].Normalize(); Planes[1] = new Vector4(items[3] + items[0], items[7] + items[4], items[11] + items[8], items[15] + items[12]); Planes[1].Normalize(); Planes[2] = new Vector4(items[3] + items[1], items[7] + items[5], items[11] + items[9], items[15] + items[13]); Planes[2].Normalize(); Planes[3] = new Vector4(items[3] - items[1], items[7] - items[5], items[11] - items[9], items[15] - items[13]); Planes[3].Normalize(); Planes[4] = new Vector4(items[3] - items[2], items[7] - items[6], items[11] - items[10], items[15] - items[14]); Planes[4].Normalize(); Planes[5] = new Vector4(items[3] + items[2], items[7] + items[6], items[11] + items[10], items[15] + items[14]); Planes[5].Normalize(); } // Allocation-less public bool IsPointInFrustum(float x, float y, float z) { foreach(Vector4 v in Planes) { if (v.X * x + v.Y * y + v.Z * z + v.W <= 0) return false; } return true; } public bool IsSphereInFrustum(float x, float y, float z, float radius) { foreach (Vector4 v in Planes) { if (v.X * x + v.Y * y + v.Z * z + v.W <= -radius) return false; } return true; }

Пришло время сделать тестовый вылет на реальной машине с слабым GPU: Asus EEEPC 701. Несмотря на то, что 701'ый появился в 2007 году и ретро назвать его можно лишь с натяжкой, его GPU является прямым наследником Intel i740 и, скажем так, не слишком далеко ушел от той-же GeForce 2 с точки зрения производительности.

[Лонг] Сам написал, сам полетал: как и зачем я разработал 3D-игру с нуля под компьютеры из 90-х в 2024 году?

С учётом всех оптимизацией, получаем 17-20 кадров на этом GPU что можно считать… весьма неплохим результатом, учитывая что всё ещё есть куда оптимизировать!

❯ Звук

Эта часть статьи будет без иллюстраций, поскольку звук нужно слушать :) Но тем не менее, детали реализации звуковой подсистемы в DirectX весьма интересны и значительно отличаются от современного подхода.

Дело в том, что раньше звук в играх был аппаратно-ускоренным, в том числе и 3D-позиционирование. На процессоре считать какие-то сложные эффекты по типу HRTF было слишком дорого, и поэтому на звуковых картах были свои собственные микшер и память для звуковых буферов. Абстрагированием зоопарка разного железа занималось специальное API, которое называлось DirectSound. Помимо моста между звуковой картой и приложением, DirectSound также мог выступать программным микшером и считать все 3D-эффекты на процессоре - чем мы с вами сейчас и пользуемся, поскольку в современных аудио-картах нет ничего кроме DAC/ADC и DSP.

Инициализация контекста DSound начинается с создания primary-буфера, который выступает в роли микшера перед отправкой звука на аудио-карту. Создаётся он легко:

BufferDescription desc = new BufferDescription(); desc.Flags = BufferFlags.PrimaryBuffer | BufferFlags.Control3D; primaryBuffer = Context.CreateSoundBuffer(desc);

После этого, в самом простом случае, нам достаточно лишь выгрузить PCM-поток на аудио-карту и начать его играть:

public WaveBuffer(WaveFormat fmt, byte[] pcmData) { BufferDescription desc = new BufferDescription(); desc.BufferBytes = (uint)pcmData.Length; desc.Flags = BufferFlags.ControlDefault |BufferFlags.Software; desc.Format = fmt; buffer = Engine.Current.Sound.Context.CreateSoundBuffer(desc); IntPtr data = buffer.Lock(); Marshal.Copy(pcmData, 0, data, pcmData.Length); buffer.Unlock(); } ... buffer.Play();

И всё! Да, вот так легко. BufferFlags.Software заменяется на Hardware, если необходимо аппаратное ускорение, однако перед этим важно проверить возможности устройства - так называемые Caps'ы.

❯ Ввод

Пожалуй, это самая простая часть нашей статьи :) Как я уже говорил ранее, никакого особого функционала от модуля обработки ввода не нужно, лишь получать состояние кнопок — и с этим справляется лишь один метод…

[DllImport("user32.dll")] static extern short GetAsyncKeyState(Keys vKey); public static bool GetKeyState(Keys key) { return (GetAsyncKeyState(key) & 0x8000) != 0; }

Ну что ж, основа готова, давайте перейдем к реализации самого геймплея!

❯ Пилим геймплей

Сначала нам нужно реализовать логику полёта нашего самолётика. В нашем конкретном кейсе всё просто — для поворотов используем углы Эйлера, считаем Forward-вектор (вектор, указывающий на направление прямо) и просто крутим повороты по тангажу и рысканью в нужную сторону, прибавляя к позиции самолетика Forward вектор, умноженный на скорость полёта. Правда, с таким подходом есть некоторые проблемы: выполнить петлю не получится, поскольку Forward-вектор всегда смотрит именно прямо и не учитывает обратную направленность по оси X. Для решения этой проблемы, Forward-вектор должен поворачиваться матрицей поворота.

Rotation.X += -v * (YawSpeed * Engine.Current.DeltaTime); Rotation.Y += h * (YawSpeed * Engine.Current.DeltaTime); Rotation.Z = MathUtils.Lerp(Rotation.Z, 35 * -h, 4.0f * Engine.Current.DeltaTime); Vector3 fw = GetForward(); Position.X += fw.X * (Speed * Engine.Current.DeltaTime); Position.Y += fw.Y * (Speed * Engine.Current.DeltaTime); Position.Z += fw.Z * (Speed * Engine.Current.DeltaTime);

Мы с вами хотим, чтобы камера всегда следила за нашим самолётиком. Для этого нужно взять Forward-вектор объекта и умножить каждую его компоненту на дальность от источника камеры. Эдакая бомж-версия lookat, правда с кучей ограничений по типу Gimbal lock (потеря одной из осей поворота). Чтобы камера казалась плавной и придавала динамичности игре — мы делаем EaseIn/EaseOut эффект путём неправильного использования формулы линейной интерполяции :)

Vector3 forward = GetForward(); // Adjust camera Engine.Current.Graphics.Camera.Position = new Vector3(Position.X + (forward.X * -12.0f), Position.Y + (forward.Y * -12.0f) + 4.0f, Position.Z + (forward.Z * -12.0f)); Engine.Current.Graphics.Camera.Rotation.Y = MathUtils.Lerp(Engine.Current.Graphics.Camera.Rotation.Y, Rotation.Y + (yaw * 30), 3.0f * Engine.Current.DeltaTime); Engine.Current.Graphics.Camera.Rotation.X = MathUtils.Lerp(Engine.Current.Graphics.Camera.Rotation.X, Rotation.X + (pitch * 5), 3.0f * Engine.Current.DeltaTime); Engine.Current.Graphics.Camera.MarkUpdated();
[Лонг] Сам написал, сам полетал: как и зачем я разработал 3D-игру с нуля под компьютеры из 90-х в 2024 году?

Ну, летать мы с вами уже можем… да, сильно по аркадному, но всё же :) Пришло время реализовать каких-нибудь соперников, а именно вражеские самолёты! Вообще, реализация нормального ИИ на самолетах, тем более в симуляторах — задачка очень нетривиальная, поскольку боты будут либо читерить, используя не те "рычаги" управления, что использует игрок, либо тупить и играть будет не сильно интересно.

Мои боты будут тупыми: мы просто вычисляем угол между позицией самолетика соперника и позицией игрока и интерполируем текущий угол по оси Y: получается вполне плавный поворот в сторону игрока, правда в нормальных играх ещё и компенсируют эффект «плаванья» вокруг игрока по синусоиде. Для подъёма и спуска по вертикали просто берём абсолютную величину выше/ниже:

float angle = (float)Math.Atan2(Game.Current.Player.Position.X - Position.X, Game.Current.Player.Position.Z - Position.Z); float vert = MathUtils.Clamp(Position.Y - Game.Current.Player.Position.Y, -1, 1); Rotation.X = MathUtils.Lerp(Rotation.X, vert * 35, 1.5f * Engine.Current.DeltaTime); float prevY = Rotation.Y; Rotation.Y = MathUtils.Lerp(Rotation.Y, angle * MathUtils.RadToDeg, 1.5f * Engine.Current.DeltaTime); float diffY = Rotation.Y - prevY > 0 ? 1 : -1; Rotation.Z = MathUtils.Lerp(Rotation.Z, 15 * -diffY, 4.0f * Engine.Current.DeltaTime);

Наши боты будут читерить, причём жёстко. Они будут иметь значительно большую маневренность, нежели игрок, но при этом их скорость будет сильно медленнее игрока, дабы можно было их обогнать и стряхнуть с хвоста.

[Лонг] Сам написал, сам полетал: как и зачем я разработал 3D-игру с нуля под компьютеры из 90-х в 2024 году?

Очень похожая концепция использовалась в гоночных играх нулевых, где в том же NFS Underground противники и в повороты лихо заходили, и разгонялись до 300Км/ч в попытках догнать игрока.

Пришло время потестить демку — и для того, чтобы она работала на Win98, нужно собрать враппер в VS2005. VS2017 не поддерживает компилятор 2005'ой студии, поэтому пришлось сделать отдельный проект, благо никаких современных фишек C++ я не использую и ничего адаптировать не пришлось.

[Лонг] Сам написал, сам полетал: как и зачем я разработал 3D-игру с нуля под компьютеры из 90-х в 2024 году?

Ну что ж, демка у нас есть и в этот раз я подготовился получше, чем в статье про 3dfx Voodoo: я собрал целых два тестовых стенда и попросил у подписчиков потестировать демку на своих машинах с диковинным железом из 90-х и нулевых годов.

❯ Собираем тестовый стенд

Изначально, в качестве тестового стенда должен был выступить кит, подаренный мне читателем Александром. В него входила материнка Chaintech 6vta2 с Slot-1 на борту вместо привычного сокета:

[Лонг] Сам написал, сам полетал: как и зачем я разработал 3D-игру с нуля под компьютеры из 90-х в 2024 году?

Процессор Pentium 3 550MHz с родным, немного пыльным охлаждением:

[Лонг] Сам написал, сам полетал: как и зачем я разработал 3D-игру с нуля под компьютеры из 90-х в 2024 году?

В качестве памяти — две плашки PC133 памяти типа SDRAM:

[Лонг] Сам написал, сам полетал: как и зачем я разработал 3D-игру с нуля под компьютеры из 90-х в 2024 году?

И видеокартой GeForce 4 MX 420 с пассивным охлаждением от Asus. Опытный читатель спросит мол «MX420 — видяшка 2002 года, что-то тут не так!», но Riva TNT или ATI Rage у меня к сожалению не было, а MX420 — на самом деле лишь немного модифицированный GeForce 2 2000 года выпуска!

[Лонг] Сам написал, сам полетал: как и зачем я разработал 3D-игру с нуля под компьютеры из 90-х в 2024 году?

После сборки, стенд не завелся: я осмотрел конденсаторы и обратил что по линии питания процессора и ОЗУ, оба элемента дутые. Поменял кондеры на проц — и плата завелась, правда работала нестабильно: Win98 сыпала ошибками по памяти, при том что оба модуля полностью рабочие, а установка NT и не начиналась.

[Лонг] Сам написал, сам полетал: как и зачем я разработал 3D-игру с нуля под компьютеры из 90-х в 2024 году?

В статье про 3dfx Voodoo за несколько дней до публикации материала, я судорожно писал всем сервисникам в своем городе на предмет наличия материнок с третьим пеньком и AGP-слотом на борту. И такая нашлась только у одного: в неизвестном состоянии и за 300 рублей, которую я решился взять. Она стартовала, но через раз: после замены всё тех же конденсаторов по линии питания процессора, она запустилась без каких либо проблем и дала поставить как Win98, так и Windows NT. Единственный нюанс — Pentium 3 550Mhz в слоте оказался заменен на Celeron 600Mhz в PGA370 и так даже лучше, поскольку у селерона значительно меньше L2 кэша и он должен проявлять себя ещё хуже, чем слотовой P III!

[Лонг] Сам написал, сам полетал: как и зачем я разработал 3D-игру с нуля под компьютеры из 90-х в 2024 году?

На Win98 я так и не смог нормально накатить драйвера на MSDC (Mass Storage Device Class — «флэшки»), поэтому «считерил» и поставил WinXP. Изначально я планировал ставить Win2000 — но там .NET 2.0 работает с косяками (при том что этот же самый .NET работает на Win98!).

❯ Тесты

Давайте же посмотрим, как демка идёт на трушном железе. Для наглядности, я решил записать видео:

Демка идёт в 20-25-30 кадров в зависимости от числа DrawCall'ов на сцене, что весьма неплохой результат для 640x480 и GPU с пассивным охлаждением!

Переходим к интегрированной графике, а именно к EEEPC 701 4G с Intel GMA 900 на борту! Те, кто знают что такое GMA, понимают насколько эти встройки не приспособлены для игр. Несмотря на наличие поддержки вторых шейдеров, из-за отсутствия аппаратного вершинного конвейера чип ничего не тянет. Но моя игрушка — исключение и она работает на удивление очень даже неплохо! 15-20 кадров точно есть и это при том что есть куда оптимизировать!

А дальше у нас идут тесты от подписчиков в моём Telegram-канале, которым я скинул билд и пригласил потестить демку на ретро-железе. Первый тест от читателя на ноутбуке с Pentium III и редкой встройкой Trident CyberBlade XP показал весьма неплохой результат — 15-20 кадров:

Далее тот же читатель потестил демку на ATI Rage M6 — очень и очень бодрый GPU, который выдает стабильные 20-25-30 кадров!

И читатель Даня потестил игру на ноуте Fujitsu с более ранним ATI Rage, где она не запустилась… с исключением на вызове EnumerateZBufferFormats! Ранние Rage не поддерживают Z-буфер и делают отсечение невидимых поверхностей неким собственным методом (неужто сортировка треугольников?):

[Лонг] Сам написал, сам полетал: как и зачем я разработал 3D-игру с нуля под компьютеры из 90-х в 2024 году?

❯ Заключение

Вот такая демка, мини-игрушка у меня получилось. Да, весьма примитивненько, зато прикольно, запилено за пару дней и можно полетать на виртуальных самолетиках. Также у меня естьTelegram-канал, куда я публикую различные мысли связанные с подручным ремонтом, моддингом и программированием под гаджеты прошлых лет, а также публикую туда ссылки на новые статьи и видео! Найти исходный код демки вы можете на моём Github.

Понравилась статья? Пишите своё мнение в комментариях, я старался :)

Статья...
Дтфное хрючево
Лонгерское вкуснючево
28
8
3
2
1
1
50 комментариев